Playhead sync

Good morning,

I have my tracktion engine application that has a timeline. I have a playhead that move smooth when running but when I do “PAUSE” (transport.stop(false, false)) she do a small jump. It seams that there is a problem with the sync.

I have a PositionManager that manage the position of the playhead in this way:

PositionManager.h

#pragma once

#include <JuceHeader.h>
#include "tracktion_engine/tracktion_engine.h"
#include <atomic>
#include <cmath>

class PositionManager : public juce::ChangeBroadcaster,
    private juce::AudioIODeviceCallback,
    private juce::AsyncUpdater,
    private juce::ChangeListener
{
public:
    PositionManager(tracktion::engine::Edit& editRef, tracktion::engine::TransportControl& tc, juce::AudioDeviceManager& dm);
    ~PositionManager() override;

    double getCurrentTime() const;

    void stopTransport()
    {
        transport.stop(false, false);
    }

private:
    void audioDeviceIOCallbackWithContext(const float* const* inputChannelData,
        int numInputChannels,
        float* const* outputChannelData,
        int numOutputChannels,
        int numSamples,
        const juce::AudioIODeviceCallbackContext& context) override;
    void audioDeviceAboutToStart(juce::AudioIODevice*) override;
    void audioDeviceStopped() override;

    void handleAsyncUpdate() override;
    void changeListenerCallback(juce::ChangeBroadcaster* source) override;
    double getCurrentAudibleTimeSeconds() const;

    tracktion::engine::Edit& edit;
    tracktion::engine::TransportControl& transport;
    juce::AudioDeviceManager& deviceManager;
    std::atomic<double> currentTime{ 0.0 };
    std::atomic<bool> forceBroadcastPending{ false };
    bool wasTransportPlaying = false;
    double lastBroadcastedTime = 0.0;
    double lastKnownSampleRate = 0.0;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PositionManager)
};

PositionManager.cpp

#include "PositionManager.h"

PositionManager::PositionManager(tracktion::engine::Edit& editRef,
    tracktion::engine::TransportControl& tc,
    juce::AudioDeviceManager& dm)
    : edit(editRef), transport(tc), deviceManager(dm)
{
    currentTime.store(getCurrentAudibleTimeSeconds(), std::memory_order_relaxed);
    wasTransportPlaying = transport.isPlaying();
    transport.addChangeListener(this);
    deviceManager.addAudioCallback(this);
}

PositionManager::~PositionManager()
{
    cancelPendingUpdate();
    transport.removeChangeListener(this);
    deviceManager.removeAudioCallback(this);
}

double PositionManager::getCurrentTime() const
{
    return currentTime.load(std::memory_order_relaxed);
}

void PositionManager::audioDeviceIOCallbackWithContext(const float* const* inputChannelData,
    int numInputChannels,
    float* const* outputChannelData,
    int numOutputChannels,
    int numSamples,
    const juce::AudioIODeviceCallbackContext&)
{
    juce::ignoreUnused(inputChannelData, numInputChannels, outputChannelData, numOutputChannels, numSamples);
    const bool isPlaying = transport.isPlaying();
    const bool transportStateChanged = isPlaying != wasTransportPlaying;
    wasTransportPlaying = isPlaying;
    const double newTime = getCurrentAudibleTimeSeconds();
    currentTime.store(newTime, std::memory_order_relaxed);
    const double minDelta = (numSamples > 0 && lastKnownSampleRate > 0.0)
        ? static_cast<double>(numSamples) / lastKnownSampleRate
        : 0.0;
    const double timeDelta = std::abs(newTime - lastBroadcastedTime);

    if (transportStateChanged && !isPlaying)
    {
        forceBroadcastPending.store(true, std::memory_order_release);
        triggerAsyncUpdate();
        return;
    }

    if (isPlaying && timeDelta < minDelta)
        return;

    if (!isPlaying && timeDelta < 1e-6)
        return;

    triggerAsyncUpdate();
}

void PositionManager::audioDeviceAboutToStart(juce::AudioIODevice*)
{
    currentTime.store(getCurrentAudibleTimeSeconds(), std::memory_order_relaxed);
    wasTransportPlaying = transport.isPlaying();
    lastKnownSampleRate = deviceManager.getCurrentAudioDevice() != nullptr
        ? deviceManager.getCurrentAudioDevice()->getCurrentSampleRate()
        : 0.0;
}

void PositionManager::audioDeviceStopped()
{
    lastKnownSampleRate = 0.0;
}

void PositionManager::handleAsyncUpdate()
{
    const bool forceBroadcast = forceBroadcastPending.exchange(false, std::memory_order_acq_rel);
    const double newTime = currentTime.load(std::memory_order_relaxed);

    if (forceBroadcast || std::abs(newTime - lastBroadcastedTime) > 1e-6)
    {
        lastBroadcastedTime = newTime;
        sendChangeMessage();
    }
}

void PositionManager::changeListenerCallback(juce::ChangeBroadcaster* source)
{
    if (source != &transport)
        return;

    currentTime.store(transport.getPosition().inSeconds(), std::memory_order_relaxed);
    forceBroadcastPending.store(true, std::memory_order_release);
    triggerAsyncUpdate();
}

double PositionManager::getCurrentAudibleTimeSeconds() const
{
    return transport.getPosition().inSeconds();
}

then in my SequencerComponent I have this in a

void SequencerComponent::changeListenerCallback(juce::ChangeBroadcaster* source):

 const double timeSec = positionManager.getCurrentTime();
 updatePlayheadFromTime(timeSec, !isFollowingPlayhead);

Maybe I’m doing all wrong but someone can help to achieve a real time playhead?

Thanks
Luca

In the meanwhile I found using:

double PositionManager::getCurrentAudibleTimeSeconds() const
{
    if (auto epc = transport.getCurrentPlaybackContext())
    {
        auto position = epc->getPosition();
        return position.inSeconds();
    }
}

Instead of:

double PositionManager::getCurrentAudibleTimeSeconds() const
{
    return transport.getPosition().inSeconds();
}

Seams to fix the problem :open_mouth:

Most likely because this line creates an unwanted copy.

You could try out of curiosity to change to:

auto& position = epc->getPosition();

I don’t have the tracktion sources at hand…

I ended up with this simpler code and it works:

PositionManager.h

#pragma once

#include <JuceHeader.h>
#include "tracktion_engine/tracktion_engine.h"
#include <atomic>
#include <cmath>

class PositionManager : public juce::ChangeBroadcaster,
    private juce::HighResolutionTimer,
    private juce::ChangeListener,
    private juce::AsyncUpdater
{
public:
    PositionManager(tracktion::engine::Edit& editRef, tracktion::engine::TransportControl& tc);
    ~PositionManager() override;

    double getCurrentTime() const;

    void stopTransport()
    {
        transport.stop(false, false);
    }

private:
    void handleAsyncUpdate() override;
    void changeListenerCallback(juce::ChangeBroadcaster* source) override;
    void hiResTimerCallback() override;

    tracktion::engine::Edit& edit;
    tracktion::engine::TransportControl& transport;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PositionManager)
};

PositionManger.cpp

#include "PositionManager.h"

PositionManager::PositionManager(tracktion::engine::Edit& editRef,
    tracktion::engine::TransportControl& tc)
    : edit(editRef), transport(tc)
{
    transport.addChangeListener(this);
    startTimer(10);
}

PositionManager::~PositionManager()
{
    cancelPendingUpdate();
    transport.removeChangeListener(this);
    stopTimer();
}

double PositionManager::getCurrentTime() const
{
    if (auto epc = transport.getCurrentPlaybackContext())
    {
        return epc->getPosition().inSeconds();
    }

    return transport.getPosition().inSeconds();
}

void PositionManager::handleAsyncUpdate()
{
    sendChangeMessage();
}

void PositionManager::changeListenerCallback(juce::ChangeBroadcaster* source)
{
    if (source != &transport)
        return;

    triggerAsyncUpdate();
}

void PositionManager::hiResTimerCallback()
{
    triggerAsyncUpdate();
}

Not sure if is better or not to use handleAsyncUpdate.

Thanks!

Don’t do that, getPosition() returns a TimePosition which is just a wrapper around a double, it’s just a value type.

The current time is flushed to the TransportControl on a timer so may be slightly out of sync. The EditPlaybackContext is updated in the audio callback so will be most up to date. But you might want to look at EditPlaybackContext::getAudibleTimelineTime() which also adjusts for latency introduced by PDC.

1 Like

Hi dave,

I’m confused. Why my last post working correct and how should modify it in order to do stuff in the right way?

Thanks!

What you’ve done will work until you have plugins that introduce latency. Then your playhead will be in front of the audible content. Using getAudibleTimelineTime effectively shifts this back in time by the total latency amount. (Except it’s a bit more complicated than that).

Apologies, I should have checked the sources :blush:

So here I should use EditPlaybackContext::getAudibleTimelineTime()?

Yes

Something like that:

double PositionManager::getCurrentTime() const
{
    if (auto epc = transport.getCurrentPlaybackContext())
    {
        return epc->getAudibleTimelineTime().inSeconds();
    }
    return transport.getPosition().inSeconds();
}

In this way when I click pause I have a small jump of the timeline :frowning:

Looking at Waveform, we just use TransportControl::getPosition() which handles all that latency internally.

Is there a reason you’re not just reading that directly on the message thread?

When I use return transport.getPosition().inSeconds(); and I press pause/play it jump a little bit like is not really synced.

Are you continuously drawing it or only when it is playing?

Have you looked at the demo apps? I don’t think they have this problem do they?

you mean this one tracktion_engine/examples/DemoRunner/demos/ClipLauncherDemo.h at 59a3b72e50390ec04c18bbdf4440e66574d00650 · Tracktion/tracktion_engine · GitHub ?

I tried this, a super simple window with:

#pragma once

#include <juce_gui_basics/juce_gui_basics.h>
#include <tracktion_engine/tracktion_engine.h>

class Sequencer2Component : public juce::Component,
    private juce::Timer
{
public:
    explicit Sequencer2Component(tracktion::engine::TransportControl& transportToUse);

    void paint(juce::Graphics& g) override;
    void resized() override;

private:
    void timerCallback() override;

    tracktion::engine::TransportControl& transport;

    double pixelsPerSecond = 200.0;
    int playheadX = 0;
};

and:

#include "Sequencer2Component.h"
#include <cmath>

Sequencer2Component::Sequencer2Component(tracktion::engine::TransportControl& transportToUse)
    : transport(transportToUse)
{
    setOpaque(true);
    startTimerHz(60);
}

void Sequencer2Component::resized()
{
    playheadX = (int)std::lround(getWidth() * 0.4);
}

void Sequencer2Component::paint(juce::Graphics& g)
{
    g.fillAll(juce::Colours::black);

    const double seconds = transport.getPosition().inSeconds();
    const double scrollOffsetX = seconds * pixelsPerSecond;

    {
        juce::Graphics::ScopedSaveState state(g);
        g.addTransform(juce::AffineTransform::translation((float)(-scrollOffsetX + playheadX), 0.0f));

        g.setColour(juce::Colours::darkgrey);

        for (int i = 0; i < 2000; ++i)
        {
            const float x = (float)(i * 100);
            g.drawLine(x, 0.0f, x, (float)getHeight());
        }
    }

    g.setColour(juce::Colours::white);
    g.fillRect(playheadX, 0, 2, getHeight());

    g.setFont(14.0f);
    g.drawText("t(s): " + juce::String(seconds, 6) +
        " | playing: " + juce::String(transport.isPlaying() ? "YES" : "NO"),
        10, 10, getWidth() - 20, 20, juce::Justification::left);
}

void Sequencer2Component::timerCallback()
{
    repaint();
}

and also here I have a jump when I click pause:

transport.stop(false, false);

But I still have this jump

Did you try the demo examples in TE itself?

The problem is that I’m running it inside a raspberry with 4 inch monitor and I see only “Select a demo above to begin” others button are hided :smiley: I need to check the code and try to modify or find a way to start directly the correct demo!

I achieved to run MidiRecording and the playhead run correctly so I tried a really simple component like this:

#pragma once

#include <juce_gui_basics/juce_gui_basics.h>
#include <tracktion_engine/tracktion_engine.h>

class Sequencer2Component : public juce::Component,
    private juce::Timer
{
public:
    explicit Sequencer2Component(tracktion::engine::TransportControl& transportToUse);

    void paint(juce::Graphics& g) override;
    void resized() override;

private:
    void timerCallback() override;

    tracktion::engine::TransportControl& transport;

    float pixelsPerSecond = 100.0f;
};

#include "Sequencer2Component.h"
#include <cmath>

Sequencer2Component::Sequencer2Component(tracktion::engine::TransportControl& transportToUse)
    : transport(transportToUse)
{
    setOpaque(true);
    startTimerHz(60);
}

void Sequencer2Component::resized()
{

}

void Sequencer2Component::paint(juce::Graphics& g)
{
    g.fillAll(juce::Colours::black);

    const double timeSec = transport.getPosition().inSeconds();

    const int playheadX = (int)std::lround(timeSec * pixelsPerSecond);

    g.setColour(juce::Colours::white);
    g.fillRect(playheadX, 0, 2, getHeight());

    g.setColour(juce::Colours::white);
    g.setFont(14.0f);
    g.drawText("t(s): " + juce::String(transport.getPosition().inSeconds(), 6)
        + " | playing: " + juce::String(transport.isPlaying() ? "YES" : "NO"),
        10, 10, getWidth() - 20, 20, juce::Justification::left);
}

void Sequencer2Component::timerCallback()
{
    repaint();
}

and I have the same problem of start/pause with the jump.
What I’m doing wrong? :open_mouth:

Have you tried to use float for the playhead pixel Position? Juce can draw float rectangle, too. But i’m not sure if this fix your issue. Also your rounding looks a bit tricky. Perhaps it jumps this one pixel that not fit?