diff --git a/app/audio/audiomanager.cpp b/app/audio/audiomanager.cpp
index 712565530..e948da348 100644
--- a/app/audio/audiomanager.cpp
+++ b/app/audio/audiomanager.cpp
@@ -71,6 +71,10 @@ int output_callback(const void *input, void *output, unsigned long frame_count,
max_read - read_count);
}
+ // Count all frames leaving the device (including zero-filled underrun
+ // frames) so this can serve as the playback master clock
+ device->add_output_frames(frame_count);
+
return paContinue;
}
@@ -106,10 +110,14 @@ bool AudioManager::push_to_output(const AudioParams ¶ms,
PaStreamParameters p = get_port_audio_params(params, output_device_);
+ // 0 = let PortAudio choose the buffer size
+ const unsigned long frames_per_buffer =
+ OAK_CONFIG("AudioOutputBufferSize").toUInt();
+
PaError r = Pa_OpenStream(&output_stream_, nullptr, &p,
output_params_.sample_rate(),
- paFramesPerBufferUnspecified, paNoFlag,
- output_callback, output_buffer_);
+ frames_per_buffer, paNoFlag, output_callback,
+ output_buffer_);
if (r != paNoError) {
// Unhandled error
//qCritical() << "Failed to open output stream:" << Pa_GetErrorText(r);
@@ -141,6 +149,28 @@ void AudioManager::clear_buffered_output()
output_buffer_->clear();
}
+double AudioManager::seconds() const
+{
+ if (!output_stream_ || !Pa_IsStreamActive(output_stream_)) {
+ return -1.0;
+ }
+
+ double seconds = double(output_buffer_->output_frames_consumed()) /
+ double(output_params_.sample_rate());
+
+ // Compensate for output latency so the clock reflects what is audible
+ if (const PaStreamInfo *info = Pa_GetStreamInfo(output_stream_)) {
+ seconds -= info->outputLatency;
+ }
+
+ return qMax(0.0, seconds);
+}
+
+void AudioManager::reset_output_clock()
+{
+ output_buffer_->reset_output_frames();
+}
+
PaSampleFormat AudioManager::get_port_audio_sample_format(SampleFormat fmt)
{
switch (fmt) {
diff --git a/app/audio/audiomanager.h b/app/audio/audiomanager.h
index 24245a1af..96046b897 100644
--- a/app/audio/audiomanager.h
+++ b/app/audio/audiomanager.h
@@ -30,6 +30,7 @@
#include "audiovisualwaveform.h"
#include "audio/audioprocessor.h"
#include "common/define.h"
+#include "common/playbackaudioclock.h"
#include "codec/ffmpeg/ffmpegencoder.h"
#include "render/audioplaybackcache.h"
#include "render/previewaudiodevice.h"
@@ -43,7 +44,7 @@ namespace olive
* Wraps around a QAudioOutput and AudioHybridDevice, connecting them together and exposing audio functionality to
* the rest of the system.
*/
-class AudioManager : public QObject {
+class AudioManager : public QObject, public PlaybackAudioClock {
Q_OBJECT
public:
static void create_instance();
@@ -60,6 +61,19 @@ public:
void stop_output();
+ /**
+ * @brief Seconds of audio consumed by the output device since the last reset
+ *
+ * Compensated for output latency so it represents what is actually
+ * audible. Returns a negative value when no output stream is running.
+ */
+ virtual double seconds() const override;
+
+ /**
+ * @brief Restarts the output clock at zero for a new playback run
+ */
+ void reset_output_clock();
+
PaDeviceIndex get_output_device() const
{
return output_device_;
diff --git a/app/common/CMakeLists.txt b/app/common/CMakeLists.txt
index a33befc8f..2f855f4e1 100644
--- a/app/common/CMakeLists.txt
+++ b/app/common/CMakeLists.txt
@@ -43,6 +43,7 @@ target_sources(libolive-editor PRIVATE
oiioutils.cpp
oiioutils.h
otioutils.h
+ playbackaudioclock.h
qtutils.cpp
qtutils.h
range.h
diff --git a/app/common/playbackaudioclock.h b/app/common/playbackaudioclock.h
new file mode 100644
index 000000000..2e175fb19
--- /dev/null
+++ b/app/common/playbackaudioclock.h
@@ -0,0 +1,45 @@
+/***
+
+ Oak - Non-Linear Video Editor
+ Copyright (C) 2026 Oak Team
+
+ 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 3 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 .
+
+***/
+
+#ifndef OAK_PLAYBACKAUDIOCLOCK_H
+#define OAK_PLAYBACKAUDIOCLOCK_H
+
+namespace olive
+{
+
+/**
+ * @brief Source of an audio output clock for playback timing
+ */
+class PlaybackAudioClock {
+public:
+ virtual ~PlaybackAudioClock() = default;
+
+ /**
+ * @brief Seconds of audio consumed by the output device
+ *
+ * Must return a negative value when no clocked output is running, in
+ * which case the caller should fall back to the wall clock.
+ */
+ virtual double seconds() const = 0;
+};
+
+}
+
+#endif // OAK_PLAYBACKAUDIOCLOCK_H
diff --git a/app/config/config.cpp b/app/config/config.cpp
index 4f763afc3..e29a1d5ee 100644
--- a/app/config/config.cpp
+++ b/app/config/config.cpp
@@ -210,6 +210,10 @@ void Config::set_defaults()
QStringLiteral("AudioOutputSampleFormat"), NodeValue::k_text,
QString::fromStdString(SampleFormat(SampleFormat::s16).to_string()));
+ // Output buffer size in frames, 0 = let PortAudio/the device decide
+ set_entry_internal(QStringLiteral("AudioOutputBufferSize"), NodeValue::k_int,
+ 0);
+
set_entry_internal(QStringLiteral("AudioRecordingFormat"), NodeValue::k_int,
ExportFormat::k_format_wav);
set_entry_internal(QStringLiteral("AudioRecordingCodec"), NodeValue::k_int,
diff --git a/app/dialog/preferences/tabs/preferencesaudiotab.cpp b/app/dialog/preferences/tabs/preferencesaudiotab.cpp
index ca9ef0408..74b079dab 100644
--- a/app/dialog/preferences/tabs/preferencesaudiotab.cpp
+++ b/app/dialog/preferences/tabs/preferencesaudiotab.cpp
@@ -124,6 +124,20 @@ PreferencesAudioTab::PreferencesAudioTab()
.toStdString()));
output_param_layout->addWidget(output_fmt_combo_, output_row,
1);
+
+ output_row++;
+
+ output_param_layout->addWidget(
+ new QLabel(tr("Buffer Size:")), output_row, 0);
+
+ output_buffer_size_ = new QSpinBox();
+ output_buffer_size_->setRange(0, 65536);
+ output_buffer_size_->setSpecialValueText(tr("Auto"));
+ output_buffer_size_->setSuffix(tr(" frames"));
+ output_buffer_size_->setValue(
+ OAK_CONFIG("AudioOutputBufferSize").toInt());
+ output_param_layout->addWidget(output_buffer_size_, output_row,
+ 1);
}
}
@@ -222,6 +236,7 @@ void PreferencesAudioTab::accept(MultiUndoCommand *command)
QVariant::fromValue(output_ch_layout_combo_->get_channel_layout());
OAK_CONFIG("AudioOutputSampleFormat") = QString::fromStdString(
output_fmt_combo_->get_sample_format().to_string());
+ OAK_CONFIG("AudioOutputBufferSize") = output_buffer_size_->value();
OAK_CONFIG("AudioRecordingFormat") = record_format_combo_->get_format();
OAK_CONFIG("AudioRecordingCodec") = record_options_->get_codec();
diff --git a/app/dialog/preferences/tabs/preferencesaudiotab.h b/app/dialog/preferences/tabs/preferencesaudiotab.h
index c6f365604..ec37a7b61 100644
--- a/app/dialog/preferences/tabs/preferencesaudiotab.h
+++ b/app/dialog/preferences/tabs/preferencesaudiotab.h
@@ -25,6 +25,7 @@
#include
#include
#include
+#include
#include "dialog/configbase/configdialogbase.h"
#include "dialog/export/exportaudiotab.h"
@@ -68,6 +69,11 @@ private:
ChannelLayoutComboBox *output_ch_layout_combo_;
SampleFormatComboBox *output_fmt_combo_;
+ /**
+ * @brief UI widget for the output buffer size in frames (0 = auto)
+ */
+ QSpinBox *output_buffer_size_;
+
ExportFormatComboBox *record_format_combo_;
ExportAudioTab *record_options_;
diff --git a/app/render/previewaudiodevice.cpp b/app/render/previewaudiodevice.cpp
index f66a62222..bcafba742 100644
--- a/app/render/previewaudiodevice.cpp
+++ b/app/render/previewaudiodevice.cpp
@@ -87,6 +87,7 @@ void PreviewAudioDevice::clear()
buffer_.clear();
bytes_read_ = 0;
+ output_frames_consumed_.store(0);
}
}
diff --git a/app/render/previewaudiodevice.h b/app/render/previewaudiodevice.h
index 06b92e83b..431fedb74 100644
--- a/app/render/previewaudiodevice.h
+++ b/app/render/previewaudiodevice.h
@@ -24,6 +24,8 @@
#include
+#include
+
#include "previewautocacher.h"
namespace olive
@@ -66,6 +68,27 @@ public:
void clear();
+ /**
+ * @brief Frames consumed by the audio output callback
+ *
+ * Counted in the callback itself so underrun (zero-filled) frames are
+ * included, making the value usable as a playback clock.
+ */
+ void add_output_frames(qint64 frame_count)
+ {
+ output_frames_consumed_.fetch_add(frame_count);
+ }
+
+ qint64 output_frames_consumed() const
+ {
+ return output_frames_consumed_.load();
+ }
+
+ void reset_output_frames()
+ {
+ output_frames_consumed_.store(0);
+ }
+
signals:
void notify();
@@ -79,6 +102,8 @@ private:
qint64 notify_interval_;
qint64 bytes_read_;
+
+ std::atomic output_frames_consumed_{0};
};
}
diff --git a/app/widget/viewer/viewer.cpp b/app/widget/viewer/viewer.cpp
index 96f9f6b5b..4e6e3ec6d 100644
--- a/app/widget/viewer/viewer.cpp
+++ b/app/widget/viewer/viewer.cpp
@@ -1384,6 +1384,10 @@ void ViewerWidget::finish_play_preprocess()
int64_t playback_start_time = get_timestamp();
+ // Restart the audio output clock for this playback run; the playback
+ // timer uses it as its master clock
+ AudioManager::instance()->reset_output_clock();
+
// Start audio waveform playback
if (!prequeued_audio_.isEmpty()) {
QString error;
diff --git a/app/widget/viewer/viewerdisplay.cpp b/app/widget/viewer/viewerdisplay.cpp
index 769bb530f..9d763b7bf 100644
--- a/app/widget/viewer/viewerdisplay.cpp
+++ b/app/widget/viewer/viewerdisplay.cpp
@@ -35,6 +35,7 @@
#include
#include
+#include "audio/audiomanager.h"
#include "common/define.h"
#include "common/html.h"
#include "common/qtutils.h"
@@ -1632,7 +1633,8 @@ void ViewerDisplayWidget::play(const int64_t &start_timestamp,
playback_timebase_ = timebase;
playback_speed_ = playback_speed;
- timer_.start(start_timestamp, playback_speed, timebase.to_double());
+ timer_.start(start_timestamp, playback_speed, timebase.to_double(),
+ AudioManager::instance());
if (start_updating) {
connect(this, &ViewerDisplayWidget::frame_swapped, this,
diff --git a/app/widget/viewer/viewerplaybacktimer.cpp b/app/widget/viewer/viewerplaybacktimer.cpp
index 2b7357318..22490df7d 100644
--- a/app/widget/viewer/viewerplaybacktimer.cpp
+++ b/app/widget/viewer/viewerplaybacktimer.cpp
@@ -28,16 +28,36 @@ namespace olive
void ViewerPlaybackTimer::start(const int64_t &start_timestamp,
const int &playback_speed,
- const double &timebase)
+ const double &timebase,
+ const PlaybackAudioClock *audio_clock)
{
timer_.start();
start_timestamp_ = start_timestamp;
playback_speed_ = playback_speed;
timebase_ = timebase * 1000;
+ audio_clock_ = audio_clock;
}
int64_t ViewerPlaybackTimer::get_timestamp_now() const
{
+ // The audio output clock is the master clock when available: sound card
+ // consumption is what the viewer must stay in sync with, and unlike the
+ // wall clock it cannot drift away from what is actually heard
+ if (audio_clock_) {
+ const double audio_seconds = audio_clock_->seconds();
+ if (audio_seconds >= 0.0) {
+ // At speeds other than 1x the audio tempo is scaled, so one
+ // output second corresponds to |speed| timeline seconds. The
+ // result is already in timeline frames, so it is applied
+ // signed rather than multiplied by the speed again.
+ const double timeline_ms =
+ audio_seconds * 1000.0 * qAbs(playback_speed_);
+ const int64_t frames_since_start = qFloor(timeline_ms / timebase_);
+ return start_timestamp_ +
+ frames_since_start * (playback_speed_ < 0 ? -1 : 1);
+ }
+ }
+
int64_t real_time = timer_.elapsed();
int64_t frames_since_start =
diff --git a/app/widget/viewer/viewerplaybacktimer.h b/app/widget/viewer/viewerplaybacktimer.h
index 1f87d1340..212b39c89 100644
--- a/app/widget/viewer/viewerplaybacktimer.h
+++ b/app/widget/viewer/viewerplaybacktimer.h
@@ -26,6 +26,7 @@
#include
#include "common/define.h"
+#include "common/playbackaudioclock.h"
namespace olive
{
@@ -33,7 +34,8 @@ namespace olive
class ViewerPlaybackTimer {
public:
void start(const int64_t &start_timestamp, const int &playback_speed,
- const double &timebase);
+ const double &timebase,
+ const PlaybackAudioClock *audio_clock = nullptr);
int64_t get_timestamp_now() const;
@@ -44,6 +46,8 @@ private:
int playback_speed_;
double timebase_;
+
+ const PlaybackAudioClock *audio_clock_ = nullptr;
};
}
diff --git a/core/src/render/samplebuffer.cpp b/core/src/render/samplebuffer.cpp
index 9faaf8471..194e21755 100644
--- a/core/src/render/samplebuffer.cpp
+++ b/core/src/render/samplebuffer.cpp
@@ -149,6 +149,8 @@ void SampleBuffer::speed(double speed)
return;
}
+ const size_t input_sample_count = sample_count_per_channel_;
+
sample_count_per_channel_ =
std::llround(static_cast(sample_count_per_channel_) / speed);
@@ -160,10 +162,17 @@ void SampleBuffer::speed(double speed)
}
for (size_t i = 0; i < sample_count_per_channel_; i++) {
- size_t input_index = std::floor(static_cast(i) * speed);
+ // Linear interpolation between the two nearest input samples,
+ // rather than nearest-neighbor sampling which aliases audibly
+ const double input_position = static_cast(i) * speed;
+ const size_t input_index = static_cast(input_position);
+ const double fraction = input_position - input_index;
+ const size_t next_index =
+ std::min(input_index + 1, input_sample_count - 1);
for (int j = 0; j < audio_params_.channel_count(); j++) {
- output_data[j][i] = data_[j][input_index];
+ output_data[j][i] = data_[j][input_index] * (1.0 - fraction) +
+ data_[j][next_index] * fraction;
}
}
diff --git a/tests/gtest/core_samplebuffer_test.cpp b/tests/gtest/core_samplebuffer_test.cpp
index edc86c2a5..b560552c1 100644
--- a/tests/gtest/core_samplebuffer_test.cpp
+++ b/tests/gtest/core_samplebuffer_test.cpp
@@ -251,6 +251,29 @@ TEST(CoreSampleBuffer, Speed)
EXPECT_FLOAT_EQ(b.data(0)[1], 0.3f);
}
+TEST(CoreSampleBuffer, SpeedInterpolatesBetweenSamples)
+{
+ AudioParams params = make_params();
+ SampleBuffer b(params, 4);
+ b.data(0)[0] = 0.0f;
+ b.data(0)[1] = 1.0f;
+ b.data(0)[2] = 0.0f;
+ b.data(0)[3] = -1.0f;
+
+ // 0.5x doubles the length; odd output samples sit exactly between two
+ // input samples and must be interpolated, not nearest-neighbor picked
+ b.speed(0.5);
+ ASSERT_EQ(b.sample_count(), 8u);
+ EXPECT_FLOAT_EQ(b.data(0)[0], 0.0f);
+ EXPECT_FLOAT_EQ(b.data(0)[1], 0.5f);
+ EXPECT_FLOAT_EQ(b.data(0)[2], 1.0f);
+ EXPECT_FLOAT_EQ(b.data(0)[3], 0.5f);
+ EXPECT_FLOAT_EQ(b.data(0)[4], 0.0f);
+ EXPECT_FLOAT_EQ(b.data(0)[5], -0.5f);
+ EXPECT_FLOAT_EQ(b.data(0)[6], -1.0f);
+ EXPECT_FLOAT_EQ(b.data(0)[7], -1.0f); // clamped at the last sample
+}
+
TEST(CoreSampleBuffer, UnallocatedOperationsNoCrash)
{
SampleBuffer b;
diff --git a/tests/gtest/viewer_smoke_test.cpp b/tests/gtest/viewer_smoke_test.cpp
index 15a709bd0..044b15b32 100644
--- a/tests/gtest/viewer_smoke_test.cpp
+++ b/tests/gtest/viewer_smoke_test.cpp
@@ -145,6 +145,61 @@ TEST(ViewerSmokeTimer, ZeroSpeed)
EXPECT_EQ(ts1, ts2);
}
+class FakeAudioClock : public PlaybackAudioClock {
+public:
+ virtual double seconds() const override
+ {
+ return seconds_;
+ }
+
+ double seconds_ = -1.0;
+};
+
+TEST(ViewerSmokeTimer, AudioClockDrivesTimestamp)
+{
+ FakeAudioClock clock;
+ ViewerPlaybackTimer timer;
+
+ // Start at timestamp 100, 1x speed, 32fps (exact in floating point)
+ timer.start(100, 1, 1.0 / 32.0, &clock);
+
+ // One second of consumed audio = 32 frames, regardless of wall time
+ clock.seconds_ = 1.0;
+ EXPECT_EQ(timer.get_timestamp_now(), 132);
+
+ // The audio clock fully overrides the wall clock (no sleep involved)
+ clock.seconds_ = 0.5;
+ EXPECT_EQ(timer.get_timestamp_now(), 116);
+}
+
+TEST(ViewerSmokeTimer, AudioClockScalesWithPlaybackSpeed)
+{
+ FakeAudioClock clock;
+ ViewerPlaybackTimer timer;
+
+ // At 2x speed one output second is two timeline seconds
+ timer.start(0, 2, 1.0 / 32.0, &clock);
+ clock.seconds_ = 0.5;
+ EXPECT_EQ(timer.get_timestamp_now(), 32);
+
+ // In reverse the playhead moves backward at |speed|
+ timer.start(100, -2, 1.0 / 32.0, &clock);
+ clock.seconds_ = 1.0;
+ EXPECT_EQ(timer.get_timestamp_now(), 36);
+}
+
+TEST(ViewerSmokeTimer, InvalidAudioClockFallsBackToWallClock)
+{
+ FakeAudioClock clock; // seconds() returns -1: no clocked output running
+ ViewerPlaybackTimer timer;
+
+ timer.start(0, 1, 1.0 / 24.0, &clock);
+ EXPECT_GE(timer.get_timestamp_now(), 0);
+
+ QThread::msleep(50); // 50ms > one 24fps frame period
+ EXPECT_GT(timer.get_timestamp_now(), 0);
+}
+
// ============================================================================
// Smoke Test: ViewerQueue
// ============================================================================