tidal-link-1.2.1: link/examples/linkaudio/LinkAudioRenderer.hpp
/* Copyright 2025, Ableton AG, Berlin. All rights reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* If you would like to incorporate Link into a proprietary software application,
* please contact <link-devs@ableton.com>.
*/
#pragma once
#if defined(LINK_AUDIO)
#include <ableton/LinkAudio.hpp>
#include <ableton/link_audio/Buffer.hpp>
#include <ableton/link_audio/Queue.hpp>
#include <ableton/util/FloatIntConversion.hpp>
namespace ableton
{
namespace linkaudio
{
namespace
{
template <typename T>
T cubicInterpolate(const std::array<T, 4>& p, double t)
{
double a = -0.5 * static_cast<double>(p[0]) + 1.5 * static_cast<double>(p[1])
- 1.5 * static_cast<double>(p[2]) + 0.5 * static_cast<double>(p[3]);
double b = static_cast<double>(p[0]) - 2.5 * static_cast<double>(p[1])
+ 2.0 * static_cast<double>(p[2]) - 0.5 * static_cast<double>(p[3]);
double c = -0.5 * static_cast<double>(p[0]) + 0.5 * static_cast<double>(p[2]);
auto d = static_cast<double>(p[1]);
return static_cast<T>(a * t * t * t + b * t * t + c * t + d);
}
template <typename T>
T linearInterpolate(T value, T inMin, T inMax, T outMin, T outMax)
{
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
}
} // namespace
template <typename Link>
class LinkAudioRenderer
{
struct Buffer
{
std::array<double, 512> mSamples; // Should at least hold max network buffer size
LinkAudioSource::BufferHandle::Info mInfo;
};
using Queue = link_audio::Queue<Buffer>;
public:
LinkAudioRenderer(Link& link, double& sampleRate)
: mLink(link)
, mSink(mLink, "A Sink", 4096)
, mSampleRate(sampleRate)
{
auto queue = Queue(2048, {});
mpQueueWriter = std::make_shared<typename Queue::Writer>(std::move(queue.writer()));
mpQueueReader = std::make_shared<typename Queue::Reader>(std::move(queue.reader()));
}
~LinkAudioRenderer() { mpSource.reset(); }
void send(double* pSamples,
size_t numFrames,
typename Link::SessionState sessionState,
double sampleRate,
const std::chrono::microseconds hostTime,
double quantum)
{
// We can only send audio if the sink can provide a buffer to write to
auto buffer = LinkAudioSink::BufferHandle(mSink);
if (buffer)
{
// The sink expects 16 bit integers so the doubles have to be converted
for (auto i = 0u; i < numFrames; ++i)
{
buffer.samples[i] = ableton::util::floatToInt16(pSamples[i]);
}
// The buffer is commited to Link Audio with the required timing information
const auto beatsAtBufferBegin = sessionState.beatAtTime(hostTime, quantum);
buffer.commit(sessionState,
beatsAtBufferBegin,
quantum,
static_cast<uint32_t>(numFrames),
1, // mono
static_cast<uint32_t>(sampleRate));
}
}
void receive(double* pRightSamples,
size_t numFrames,
typename Link::SessionState sessionState,
double sampleRate,
const std::chrono::microseconds hostTime,
double quantum)
{
// Get all slots from the queue
while (mpQueueReader->retainSlot())
{
}
// Calculate the beat range we want to render to the output buffer
constexpr auto kLatencyInBeats = 4;
const auto targetBeatsAtBufferBegin =
sessionState.beatAtTime(hostTime, quantum) - kLatencyInBeats;
const auto targetBeatsAtBufferEnd =
sessionState.beatAtTime(
hostTime
+ std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::duration<double>((double(numFrames)) / sampleRate)),
quantum)
- kLatencyInBeats;
// Drop slots that are too old while we are not rendering
while (!moStartReadPos && mpQueueReader->numRetainedSlots() > 0)
{
if ((*mpQueueReader)[0]->mInfo.endBeats(sessionState, quantum)
< targetBeatsAtBufferBegin)
{
mpQueueReader->releaseSlot();
}
else
{
break;
}
}
// Return early if there is no audio buffer to read from
if (mpQueueReader->numRetainedSlots() == 0)
{
moLastFrameIdx = std::nullopt;
moStartReadPos = std::nullopt;
mBuffered = 0;
return;
}
// Return early if the next buffer is too new and we are not rendering
if (!moStartReadPos
&& (*mpQueueReader)[0]->mInfo.beginBeats(sessionState, quantum)
> targetBeatsAtBufferBegin)
{
moLastFrameIdx = std::nullopt;
moStartReadPos = std::nullopt;
mBuffered = 0;
return;
}
// Initialize start read position if not set
if (!moStartReadPos)
{
const auto& info = (*mpQueueReader)[0]->mInfo;
const auto startBufferBegin = *info.beginBeats(sessionState, quantum);
const auto startBufferEnd = *info.endBeats(sessionState, quantum);
// Calculate initial position from target beat
moStartReadPos = linearInterpolate(targetBeatsAtBufferBegin,
startBufferBegin,
startBufferEnd,
0.0,
double(info.numFrames));
}
const auto startFramePos = *moStartReadPos;
// Loop through queue to find end beat and collect total frames
auto totalFrames = 0.0;
auto foundEnd = false;
for (auto i = 0u; i < mpQueueReader->numRetainedSlots(); ++i)
{
const auto& info = (*mpQueueReader)[i]->mInfo;
const auto bufferBegin = *info.beginBeats(sessionState, quantum);
const auto bufferEnd = *info.endBeats(sessionState, quantum);
if (targetBeatsAtBufferEnd >= bufferBegin && targetBeatsAtBufferEnd < bufferEnd)
{
const auto targetBeatsFrame = linearInterpolate(
targetBeatsAtBufferEnd, bufferBegin, bufferEnd, 0.0, double(info.numFrames));
totalFrames += targetBeatsFrame;
foundEnd = true;
break;
}
else
{
totalFrames += double(info.numFrames);
}
}
// We don't have enough frames buffered
if (!foundEnd)
{
moLastFrameIdx = std::nullopt;
moStartReadPos = std::nullopt;
mBuffered = 0;
return;
}
// Take the frames we have already rendered into account
totalFrames -= startFramePos;
// Beat time jump, we can't continue rendering
if (totalFrames <= 0.0)
{
moLastFrameIdx = std::nullopt;
moStartReadPos = std::nullopt;
mBuffered = 0;
return;
}
// The increment is how many source frames to advance per output frame
// This automatically handles both tempo changes and sample rate differences by
// re-pitching the audio
const auto frameIncrement = totalFrames / double(numFrames);
auto readPos = startFramePos;
// Helper to get sample at index, handling buffer boundaries
auto getSample = [&](size_t idx) -> double
{
size_t bufferIdx = 0;
size_t currentIdx = idx;
while (bufferIdx < mpQueueReader->numRetainedSlots())
{
auto& currentBuffer = *((*mpQueueReader)[bufferIdx]);
if (currentIdx < currentBuffer.mInfo.numFrames)
{
return currentBuffer.mSamples[currentIdx];
}
currentIdx -= currentBuffer.mInfo.numFrames;
++bufferIdx;
}
return 0.0;
};
for (auto frame = 0u; frame < numFrames; ++frame)
{
const auto framePos = readPos + frame * frameIncrement;
const auto frameIdx = static_cast<size_t>(std::floor(framePos));
const auto t = framePos - std::floor(framePos);
// Update the interpolator cache
while (!moLastFrameIdx || (moLastFrameIdx && frameIdx > *moLastFrameIdx))
{
mReceiverSampleCache[3] = mReceiverSampleCache[2];
mReceiverSampleCache[2] = mReceiverSampleCache[1];
mReceiverSampleCache[1] = mReceiverSampleCache[0];
mReceiverSampleCache[0] = (frameIdx > 0) ? getSample(frameIdx - 1) : getSample(0);
moLastFrameIdx = moLastFrameIdx ? (*moLastFrameIdx + 1) : frameIdx;
}
// Write the output frame
pRightSamples[frame] = cubicInterpolate(mReceiverSampleCache, t);
// Drop buffer if we've moved past it
const auto& currentInfo = (*mpQueueReader)[0]->mInfo;
if (frameIdx >= currentInfo.numFrames)
{
readPos -= double(currentInfo.numFrames);
moLastFrameIdx = frameIdx - currentInfo.numFrames;
mpQueueReader->releaseSlot();
}
}
// Update the read position for the next buffer
*moStartReadPos = readPos + double(numFrames) * frameIncrement;
// Calculate buffered time
auto buffered =
-static_cast<float>(*moStartReadPos) / float((*mpQueueReader)[0]->mInfo.sampleRate);
if (mpQueueReader->numRetainedSlots() > 0)
{
for (auto i = 1u; i < mpQueueReader->numRetainedSlots(); ++i)
{
const auto& info = (*mpQueueReader)[i]->mInfo;
buffered += float(info.numFrames) / float(info.sampleRate);
}
}
mBuffered = buffered;
}
void operator()(double* pLeftSamples,
double* pRightSamples,
size_t numFrames,
typename Link::SessionState sessionState,
double sampleRate,
const std::chrono::microseconds hostTime,
double quantum)
{
// Send the left channel to Link
send(pLeftSamples, numFrames, sessionState, sampleRate, hostTime, quantum);
// Write the received audio to the right channel
receive(pRightSamples, numFrames, sessionState, sampleRate, hostTime, quantum);
}
bool hasSource() const { return mpSource != nullptr; }
void removeSource()
{
if (mpSource)
{
mpSource.reset();
while (mpQueueReader->retainSlot())
{
}
while (mpQueueReader->numRetainedSlots() > 0)
{
mpQueueReader->releaseSlot();
}
moLastFrameIdx = std::nullopt;
moStartReadPos = std::nullopt;
}
}
float buffered() const { return mBuffered; }
void createSource(const ChannelId& channelId)
{
mpSource = std::make_unique<LinkAudioSource>(
mLink,
channelId,
[this](ableton::LinkAudioSource::BufferHandle bufferHandle)
{ onSourceBuffer(bufferHandle); });
}
void onSourceBuffer(const LinkAudioSource::BufferHandle bufferHandle)
{
// When we receive a buffer from Link, we prime it for the audio rendering callback.
// This runs on the main Link thread, so we should not block it for too long.
if (mpQueueWriter->retainSlot())
{
auto& buffer = *((*mpQueueWriter)[0]);
buffer.mInfo = bufferHandle.info;
buffer.mInfo.numChannels = 1;
for (auto i = 0u; i < bufferHandle.info.numFrames; ++i)
{
buffer.mSamples[i] = util::int16ToFloat<double>(
bufferHandle.samples[i * bufferHandle.info.numChannels]);
}
mpQueueWriter->releaseSlot();
}
}
Link& mLink;
LinkAudioSink mSink;
std::unique_ptr<LinkAudioSource> mpSource;
double& mSampleRate;
std::optional<double> moStartReadPos;
std::atomic<float> mBuffered = 0;
private:
std::shared_ptr<typename Queue::Writer> mpQueueWriter;
std::shared_ptr<typename Queue::Reader> mpQueueReader;
std::array<double, 4> mReceiverSampleCache = {{0.0, 0.0, 0.0, 0.0}};
std::optional<size_t> moLastFrameIdx = std::nullopt;
};
} // namespace linkaudio
} // namespace ableton
#else
namespace ableton
{
namespace linkaudio
{
template <typename Link>
class LinkAudioRenderer
{
public:
LinkAudioRenderer(Link&, double&) {}
// In case we don't support LinkAudio we just copy the left output buffer to the right
void operator()(double* pLeftSamples,
double* pRightSamples,
size_t numFrames,
typename Link::SessionState,
double,
const std::chrono::microseconds,
double)
{
std::copy_n(pLeftSamples, numFrames, pRightSamples);
}
};
} // namespace linkaudio
} // namespace ableton
#endif