diff --git a/include/audio/error.h b/include/audio/error.h
new file mode 100644
index 000000000..a70cb1927
--- /dev/null
+++ b/include/audio/error.h
@@ -0,0 +1,59 @@
+/***
+
+ Oak Video Editor - 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_EDITOR_AUDIO_ERROR_H
+#define OAK_EDITOR_AUDIO_ERROR_H
+
+/**
+ * @brief Current ABI version stamped into every oakaudio handle.
+ *
+ * Bump whenever a handle layout or the semantics of any exported function
+ * change incompatibly. Consumers should compare a handle's abi_version
+ * field against the value they were compiled with before dereferencing
+ * ctx.
+ */
+#define OAKAUDIO_ABI_VERSION 1
+
+#if defined(_WIN32)
+#if defined(OAKAUDIO_BUILD)
+#define OAKAUDIO_API __declspec(dllexport)
+#else
+#define OAKAUDIO_API __declspec(dllimport)
+#endif
+#else
+#define OAKAUDIO_API __attribute__((visibility("default")))
+#endif
+
+/**
+ * @brief Status and error codes shared by all oakaudio C API families.
+ *
+ * Return-code convention (mirrors engine/include/oakengine/init.h):
+ * 0 (OAKAUDIO_OK) on success, a negative OAKAUDIO_E_* error code on
+ * failure. String getters return the required buffer size in bytes
+ * (including the terminating NUL) as a non-negative value instead.
+ */
+#define OAKAUDIO_OK 0 /**< Success. */
+#define OAKAUDIO_E_INVALID (-1) /**< NULL handle or invalid argument. */
+#define OAKAUDIO_E_STATE (-2) /**< Call not valid in the current state. */
+#define OAKAUDIO_E_FAILED (-3) /**< The underlying operation failed. */
+#define OAKAUDIO_E_NOT_FOUND (-4) /**< Index out of range / entry not found. */
+#define OAKAUDIO_E_NOMEM (-5) /**< Allocation failed. */
+
+#endif //OAK_EDITOR_AUDIO_ERROR_H
diff --git a/include/audio/levelmeter.h b/include/audio/levelmeter.h
new file mode 100644
index 000000000..928a155db
--- /dev/null
+++ b/include/audio/levelmeter.h
@@ -0,0 +1,73 @@
+/***
+
+ Oak Video Editor - 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_EDITOR_AUDIO_LEVELMETER_H
+#define OAK_EDITOR_AUDIO_LEVELMETER_H
+
+#include "error.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @file levelmeter.h
+ * @brief C ABI for the oakaudio level meter (olive::AudioLevelMeter):
+ * stateless peak/RMS/VU/LUFS analysis of planar float audio.
+ */
+
+/** Per-channel analysis results. dB fields floor at -200. */
+typedef struct oakaudio_channel_stats {
+ double peak_linear;
+ double peak_db;
+ double rms_linear;
+ double rms_db;
+ double vu_db;
+} oakaudio_channel_stats;
+
+/** Buffer-wide summary. */
+typedef struct oakaudio_meter_stats {
+ double max_peak_linear;
+ double integrated_lufs; /**< BS.1770-compatible unit (no K-weighting). */
+ int silence; /**< 1 when the buffer is (near-)silent. */
+} oakaudio_meter_stats;
+
+/**
+ * @brief Analyze a planar float buffer.
+ *
+ * @param planar Per-channel float planes.
+ * @param channel_count Number of channels (> 0).
+ * @param frame_count Frames per channel (>= 0).
+ * @param channels Receives per-channel stats; may be NULL.
+ * @param channels_capacity Capacity of `channels` (must be >=
+ * channel_count when channels is non-NULL).
+ * @param summary Receives the buffer-wide summary; may be NULL.
+ * @return OAKAUDIO_OK or OAKAUDIO_E_INVALID.
+ */
+OAKAUDIO_API int oakaudio_levelmeter_analyze(const float *const *planar,
+ int channel_count, int frame_count,
+ oakaudio_channel_stats *channels, int channels_capacity,
+ oakaudio_meter_stats *summary);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif //OAK_EDITOR_AUDIO_LEVELMETER_H
diff --git a/include/audio/manager.h b/include/audio/manager.h
new file mode 100644
index 000000000..9861511fc
--- /dev/null
+++ b/include/audio/manager.h
@@ -0,0 +1,176 @@
+/***
+
+ Oak Video Editor - 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_EDITOR_AUDIO_MANAGER_H
+#define OAK_EDITOR_AUDIO_MANAGER_H
+
+#include
+
+#include "codec/encoder.h"
+#include "error.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @file manager.h
+ * @brief C ABI for the oakaudio PortAudio output/input manager
+ * (olive::AudioManager singleton).
+ *
+ * OakAudioManager uses the standard handle layout (see oakcommon's
+ * common/handle.h) but with singleton semantics: ctx points to the
+ * process-wide instance created by oakaudio_manager_create_instance(), so
+ * addref() and release() are intentionally no-ops and never destroy
+ * anything (mirrors oakcommon's OakCurrent). abi_version is always
+ * OAKAUDIO_ABI_VERSION.
+ *
+ * Device indices are PortAudio PaDeviceIndex values (-1 = paNoDevice).
+ * Sample formats are olive::core::SampleFormat::Format values.
+ */
+typedef struct OakAudioManager {
+ void *ctx; /**< Opaque pointer to the singleton object. */
+ void (*addref)(void *ctx); /**< No-op (singleton). */
+ void (*release)(void *ctx); /**< No-op (singleton). */
+ uint32_t abi_version; /**< OAKAUDIO_ABI_VERSION. */
+} OakAudioManager;
+
+/**
+ * @brief Create the process-wide AudioManager (no-op when it exists).
+ *
+ * Initializes PortAudio and picks the configured/default devices.
+ *
+ * @return OAKAUDIO_OK or OAKAUDIO_E_NOMEM.
+ */
+OAKAUDIO_API int oakaudio_manager_create_instance(void);
+
+/**
+ * @brief Destroy the process-wide AudioManager (no-op when absent).
+ */
+OAKAUDIO_API void oakaudio_manager_destroy_instance(void);
+
+/**
+ * @brief Return a handle to the process-wide AudioManager.
+ *
+ * The returned handle is borrowed; addref/release are no-ops. When no
+ * instance exists the handle is empty (ctx == NULL) and all functions
+ * report OAKAUDIO_E_STATE.
+ */
+OAKAUDIO_API OakAudioManager oakaudio_manager_instance(void);
+
+/**
+ * @brief Release a manager handle. No-op (singleton), safe on NULL/empty.
+ */
+OAKAUDIO_API void oakaudio_manager_free(OakAudioManager *self);
+
+/**
+ * @brief Bytes between output-notify pulses (0 disables).
+ */
+OAKAUDIO_API int oakaudio_manager_set_output_notify_interval(
+ OakAudioManager self, int64_t bytes);
+
+/**
+ * @brief Push a block of samples to the output device, opening/restarting
+ * the stream when the params changed.
+ *
+ * @param rate/layout/format Stream params (ffmpeg-style layout mask,
+ * SampleFormat::Format int).
+ * @param samples Packed samples in the given format.
+ * @param samples_size Byte count of `samples`.
+ * @param error_buf/error_buf_size Optional human-readable failure detail.
+ * @return OAKAUDIO_OK, OAKAUDIO_E_INVALID, OAKAUDIO_E_STATE (no output
+ * device), or OAKAUDIO_E_FAILED (PortAudio error, see error_buf).
+ */
+OAKAUDIO_API int oakaudio_manager_push_to_output(OakAudioManager self,
+ int rate, uint64_t layout, int format,
+ const char *samples, int64_t samples_size,
+ char *error_buf, int error_buf_size);
+
+OAKAUDIO_API int oakaudio_manager_clear_buffered_output(OakAudioManager self);
+OAKAUDIO_API int oakaudio_manager_stop_output(OakAudioManager self);
+
+/**
+ * @brief Seconds of audio consumed by the output device since the last
+ * reset, compensated for output latency; negative when no stream
+ * is running.
+ */
+OAKAUDIO_API int oakaudio_manager_seconds(OakAudioManager self, double *out);
+
+OAKAUDIO_API int oakaudio_manager_reset_output_clock(OakAudioManager self);
+
+/**
+ * @brief Current output device index, paNoDevice (-1), or a negative
+ * OAKAUDIO_E_* code.
+ */
+OAKAUDIO_API int oakaudio_manager_get_output_device(OakAudioManager self);
+OAKAUDIO_API int oakaudio_manager_set_output_device(OakAudioManager self,
+ int device);
+OAKAUDIO_API int oakaudio_manager_get_input_device(OakAudioManager self);
+OAKAUDIO_API int oakaudio_manager_set_input_device(OakAudioManager self,
+ int device);
+
+/**
+ * @brief Close the output stream and re-initialize PortAudio.
+ */
+OAKAUDIO_API int oakaudio_manager_hard_reset(OakAudioManager self);
+
+/**
+ * @brief Start recording the input device to a file via the oakcodec
+ * encoder C ABI.
+ *
+ * `params` must describe an audio-enabled encoding; the input stream is
+ * always captured as interleaved 32-bit float (the only format the
+ * oakcodec encoder write path accepts).
+ *
+ * @return OAKAUDIO_OK, OAKAUDIO_E_STATE (no input device), or
+ * OAKAUDIO_E_FAILED (see error_buf).
+ */
+OAKAUDIO_API int oakaudio_manager_start_recording(OakAudioManager self,
+ const oakcodec_encoding_params *params,
+ char *error_buf, int error_buf_size);
+
+OAKAUDIO_API int oakaudio_manager_stop_recording(OakAudioManager self);
+
+/**
+ * @brief Device index named by the configuration ("AudioOutput" /
+ * "AudioInput"), or the default device when unset/unmatched.
+ * Static: valid without an instance (PortAudio must be initialized
+ * by an instance first; returns paNoDevice otherwise).
+ */
+OAKAUDIO_API int oakaudio_manager_find_config_device_by_name_s(
+ int is_output_device);
+
+/**
+ * @brief Device index whose name matches `name` exactly (empty name
+ * matches nothing, falls through to the default device).
+ */
+OAKAUDIO_API int oakaudio_manager_find_device_by_name_s(const char *name,
+ int is_output_device);
+
+/**
+ * @brief Number of live oakaudio reference-counted objects (leak check).
+ */
+OAKAUDIO_API int oakaudio_debug_alive_count(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif //OAK_EDITOR_AUDIO_MANAGER_H
diff --git a/include/audio/processor.h b/include/audio/processor.h
new file mode 100644
index 000000000..547d89232
--- /dev/null
+++ b/include/audio/processor.h
@@ -0,0 +1,131 @@
+/***
+
+ Oak Video Editor - 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_EDITOR_AUDIO_PROCESSOR_H
+#define OAK_EDITOR_AUDIO_PROCESSOR_H
+
+#include
+
+#include "error.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @file processor.h
+ * @brief C ABI for the oakaudio real-time resampler/format converter
+ * (olive::AudioProcessor).
+ *
+ * OakAudioProcessor follows the neutral by-value handle convention (see
+ * oakcommon's common/handle.h): oakaudio_processor_init() returns a handle
+ * whose underlying object has reference count 1, the addref and release
+ * function pointers adjust that count atomically (release destroys the
+ * object at zero), and abi_version is always OAKAUDIO_ABI_VERSION.
+ * Functions that only use a handle take it BY VALUE; an empty handle
+ * (ctx == NULL) is reported as OAKAUDIO_E_INVALID.
+ *
+ * Sample formats are passed as ints matching the
+ * olive::core::SampleFormat::Format enum values (invalid = -1, u8_p = 0,
+ * s16_p, s32_p, s64_p, f32_p, f64_p, u8, s16, s32, s64, f32, f64,
+ * count). Channel layouts are ffmpeg-style channel masks.
+ */
+typedef struct OakAudioProcessor {
+ void *ctx; /**< Opaque pointer to the reference-counted object. */
+ void (*addref)(void *ctx); /**< Atomically increments the count. */
+ void (*release)(void *ctx); /**< Decrements the count, destroys at 0. */
+ uint32_t abi_version; /**< OAKAUDIO_ABI_VERSION. */
+} OakAudioProcessor;
+
+/** oakaudio_processor_convert() delivers planar 32-bit float output. */
+#define OAKAUDIO_PROCESSOR_OUTPUT_FORMAT 4 /**< SampleFormat::f32_p. */
+
+/**
+ * @brief Create a closed audio processor (count 1).
+ *
+ * @return Handle with reference count 1; ctx is NULL on allocation
+ * failure.
+ */
+OAKAUDIO_API OakAudioProcessor oakaudio_processor_init(void);
+
+/**
+ * @brief Release one reference to a processor.
+ *
+ * Convenience wrapper around self->release(self->ctx); nulls self->ctx.
+ * No-op when self is NULL or self->ctx is NULL.
+ */
+OAKAUDIO_API void oakaudio_processor_free(OakAudioProcessor *self);
+
+/**
+ * @brief Open the resampling/format-conversion graph.
+ *
+ * out_format is accepted for interface completeness but the conversion
+ * output is always planar 32-bit float (see
+ * OAKAUDIO_PROCESSOR_OUTPUT_FORMAT); passing any other format returns
+ * OAKAUDIO_E_INVALID. A channel layout mask of 0 falls back to the
+ * default layout for the channel count (stereo when unknown), matching
+ * the C++ implementation.
+ *
+ * @param speed Tempo factor (1.0 = unchanged).
+ * @return OAKAUDIO_OK, OAKAUDIO_E_STATE when already open,
+ * OAKAUDIO_E_INVALID for bad arguments, or OAKAUDIO_E_FAILED when
+ * the filter graph could not be created.
+ */
+OAKAUDIO_API int oakaudio_processor_open(OakAudioProcessor self,
+ int in_rate, uint64_t in_layout, int in_format,
+ int out_rate, uint64_t out_layout, int out_format, double speed);
+
+/**
+ * @brief Close the graph (safe when closed; self must be non-empty).
+ */
+OAKAUDIO_API int oakaudio_processor_close(OakAudioProcessor self);
+
+/**
+ * @brief 1 when open, 0 when closed, OAKAUDIO_E_INVALID for empty handle.
+ */
+OAKAUDIO_API int oakaudio_processor_is_open(OakAudioProcessor self);
+
+/**
+ * @brief Push planar float input and pull converted output.
+ *
+ * @param in_planar Per-channel float input planes (in channel count);
+ * NULL with in_frame_count == 0 only pulls pending output.
+ * @param in_frame_count Frames per input channel.
+ * @param out_planar Per-channel float output planes (out channel count);
+ * NULL to discard/pull nothing (returns 0).
+ * @param out_capacity_frames Capacity of each output plane in frames.
+ * @return Number of output frames written (>= 0), or a negative
+ * OAKAUDIO_E_* code. Output is clamped to out_capacity_frames;
+ * remaining frames stay queued in the graph.
+ */
+OAKAUDIO_API int oakaudio_processor_convert(OakAudioProcessor self,
+ const float *const *in_planar, int in_frame_count,
+ float *const *out_planar, int out_capacity_frames);
+
+/**
+ * @brief Signal end-of-input to the graph (flushes internal delay).
+ */
+OAKAUDIO_API int oakaudio_processor_flush(OakAudioProcessor self);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif //OAK_EDITOR_AUDIO_PROCESSOR_H
diff --git a/include/audio/sync.h b/include/audio/sync.h
new file mode 100644
index 000000000..fdd9a630f
--- /dev/null
+++ b/include/audio/sync.h
@@ -0,0 +1,132 @@
+/***
+
+ Oak Video Editor - 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_EDITOR_AUDIO_SYNC_H
+#define OAK_EDITOR_AUDIO_SYNC_H
+
+#include
+
+#include "error.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @file sync.h
+ * @brief C ABI for the oakaudio synchronization helpers
+ * (olive::AudioSynchronizer and olive::AudioWaveformSync):
+ * stateless source-time placement and envelope-correlation offset
+ * estimation.
+ */
+
+/** Result of an offset estimation. */
+typedef struct oakaudio_offset_result {
+ int64_t offset_samples;
+ double confidence; /**< 0..1 correlation score. */
+ int valid; /**< 1 when an estimate was found. */
+} oakaudio_offset_result;
+
+/** Result of a stretch-plus-offset estimation. */
+typedef struct oakaudio_stretch_offset_result {
+ double rate; /**< Playback rate aligning the candidate (> 1 = speed up). */
+ int64_t offset_samples;
+ double confidence;
+ int valid;
+} oakaudio_stretch_offset_result;
+
+/**
+ * @brief Per-window RMS envelope of a planar float buffer (static).
+ *
+ * @return Number of envelope windows (>= 0) or a negative OAKAUDIO_E_*
+ * code. When out is NULL or too small, the required window count
+ * is returned and nothing is written.
+ */
+OAKAUDIO_API int oakaudio_sync_extract_rms_envelope(
+ const float *const *planar, int channel_count, int frame_count,
+ uint64_t window_samples, double *out, int capacity);
+
+/**
+ * @brief Estimate the candidate's offset against the reference by
+ * normalized cross-correlation of RMS envelopes.
+ *
+ * @param reference_valid/candidate_valid Optional per-window validity
+ * masks (NULL = all windows valid; when non-NULL the length must
+ * match the corresponding envelope length).
+ */
+OAKAUDIO_API int oakaudio_sync_estimate_envelope_offset(
+ const double *reference, int reference_len,
+ const double *candidate, int candidate_len,
+ const uint8_t *reference_valid, const uint8_t *candidate_valid,
+ uint64_t window_samples, int64_t max_offset_windows,
+ oakaudio_offset_result *out);
+
+/**
+ * @brief Estimate a playback-rate change plus offset aligning the
+ * candidate to the reference.
+ *
+ * The candidate envelope is resampled at each rate in
+ * [min_rate, max_rate] (step rate_step) and correlated against the
+ * reference. O(rates * lags * overlap); bound max_offset_windows.
+ */
+OAKAUDIO_API int oakaudio_sync_estimate_stretch_and_offset(
+ const double *reference, int reference_len,
+ const double *candidate, int candidate_len,
+ const uint8_t *reference_valid, const uint8_t *candidate_valid,
+ uint64_t window_samples, int64_t max_offset_windows,
+ double min_rate, double max_rate, double rate_step,
+ oakaudio_stretch_offset_result *out);
+
+/** One clip's source-time metadata (rational seconds). */
+typedef struct oakaudio_source_clip {
+ int64_t source_start_time_num;
+ int64_t source_start_time_den;
+ int64_t media_in_num;
+ int64_t media_in_den;
+ int has_source_start_time;
+} oakaudio_source_clip;
+
+/**
+ * @brief Place the candidate on the timeline so its source time aligns
+ * with the reference clip.
+ *
+ * @param reference_timeline_in_num/den Reference clip's timeline in point.
+ * @param out_num/out_den Receive the candidate's timeline in point.
+ * @param out_valid Receives 1 when placement succeeded.
+ */
+OAKAUDIO_API int oakaudio_sync_place_by_source_time(
+ const oakaudio_source_clip *reference,
+ const oakaudio_source_clip *candidate,
+ int64_t reference_timeline_in_num, int64_t reference_timeline_in_den,
+ int64_t *out_num, int64_t *out_den, int *out_valid);
+
+/**
+ * @brief Timeline placement from a measured waveform offset.
+ */
+OAKAUDIO_API int oakaudio_sync_place_by_waveform_offset(
+ int64_t reference_timeline_in_num, int64_t reference_timeline_in_den,
+ int64_t candidate_offset_samples, int sample_rate,
+ int64_t *out_num, int64_t *out_den, int *out_valid);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif //OAK_EDITOR_AUDIO_SYNC_H
diff --git a/include/audio/waveform.h b/include/audio/waveform.h
new file mode 100644
index 000000000..6dbb173c2
--- /dev/null
+++ b/include/audio/waveform.h
@@ -0,0 +1,179 @@
+/***
+
+ Oak Video Editor - 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_EDITOR_AUDIO_WAVEFORM_H
+#define OAK_EDITOR_AUDIO_WAVEFORM_H
+
+#include
+
+#include "error.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @file waveform.h
+ * @brief C ABI for the oakaudio visual waveform store
+ * (olive::AudioVisualWaveform) and whole-file waveform extraction.
+ *
+ * OakAudioWaveform follows the neutral by-value handle convention (see
+ * oakcommon's common/handle.h). Times are rationals as (num, den) pairs
+ * of int64_t in seconds; den must be non-zero.
+ *
+ * Summaries are stored as channel-interleaved min/max pairs: point p of
+ * channel c lives at pairs[p * channel_count + c]. This matches the
+ * on-disk/cache layout of the engine's waveform data (min/max float
+ * pairs), so the extraction output is drop-in compatible.
+ */
+
+/** One summarized waveform point of one channel. */
+typedef struct oakaudio_min_max {
+ float min;
+ float max;
+} oakaudio_min_max;
+
+typedef struct OakAudioWaveform {
+ void *ctx; /**< Opaque pointer to the reference-counted object. */
+ void (*addref)(void *ctx); /**< Atomically increments the count. */
+ void (*release)(void *ctx); /**< Decrements the count, destroys at 0. */
+ uint32_t abi_version; /**< OAKAUDIO_ABI_VERSION. */
+} OakAudioWaveform;
+
+/**
+ * @brief Create an empty waveform (count 1, channel count 0).
+ */
+OAKAUDIO_API OakAudioWaveform oakaudio_waveform_init(void);
+
+/**
+ * @brief Release one reference. No-op on NULL/empty handle.
+ */
+OAKAUDIO_API void oakaudio_waveform_free(OakAudioWaveform *self);
+
+/**
+ * @brief Channel count, or a negative OAKAUDIO_E_* code.
+ */
+OAKAUDIO_API int oakaudio_waveform_get_channel_count(OakAudioWaveform self);
+OAKAUDIO_API int oakaudio_waveform_set_channel_count(OakAudioWaveform self,
+ int channels);
+
+/**
+ * @brief Waveform length in seconds as a rational pair.
+ */
+OAKAUDIO_API int oakaudio_waveform_length(OakAudioWaveform self,
+ int64_t *num, int64_t *den);
+
+/**
+ * @brief Write planar float samples into the waveform at `start` seconds,
+ * expanding it if necessary.
+ *
+ * @param planar Per-channel float planes; channel count is taken from the
+ * waveform (set it first with oakaudio_waveform_set_channel_count).
+ */
+OAKAUDIO_API int oakaudio_waveform_overwrite_samples(OakAudioWaveform self,
+ const float *const *planar, int frame_count, int sample_rate,
+ int64_t start_num, int64_t start_den);
+
+/**
+ * @brief Copy summarized data from another waveform over this one.
+ *
+ * @param dest_num/dest_den Where in `self` the sums start being written.
+ * @param offset_num/offset_den Where in `src` reading starts.
+ * @param length_num/length_den Maximum amount to copy; 0/1 = all of src.
+ */
+OAKAUDIO_API int oakaudio_waveform_overwrite_sums(OakAudioWaveform self,
+ OakAudioWaveform src,
+ int64_t dest_num, int64_t dest_den,
+ int64_t offset_num, int64_t offset_den,
+ int64_t length_num, int64_t length_den);
+
+OAKAUDIO_API int oakaudio_waveform_overwrite_silence(OakAudioWaveform self,
+ int64_t start_num, int64_t start_den,
+ int64_t length_num, int64_t length_den);
+
+/**
+ * @brief Drop `length` seconds from the front (negative prepends silence).
+ */
+OAKAUDIO_API int oakaudio_waveform_trim_in(OakAudioWaveform self,
+ int64_t length_num, int64_t length_den);
+
+OAKAUDIO_API int oakaudio_waveform_resize(OakAudioWaveform self,
+ int64_t length_num, int64_t length_den);
+
+OAKAUDIO_API int oakaudio_waveform_trim_range(OakAudioWaveform self,
+ int64_t in_num, int64_t in_den,
+ int64_t length_num, int64_t length_den);
+
+/**
+ * @brief Summarized min/max pairs covering [start, start+length).
+ *
+ * @param out_pairs Receives points * channel_count channel-interleaved
+ * pairs; may be NULL to query the point count.
+ * @param capacity_points Capacity of out_pairs in points.
+ * @return Number of points (>= 0), or a negative OAKAUDIO_E_* code.
+ * When out_pairs is NULL or too small the required count is
+ * returned and nothing is written.
+ */
+OAKAUDIO_API int oakaudio_waveform_get_summary(OakAudioWaveform self,
+ int64_t start_num, int64_t start_den,
+ int64_t length_num, int64_t length_den,
+ oakaudio_min_max *out_pairs, int capacity_points);
+
+/**
+ * @brief Min/max of `length` samples starting at `start_index` for every
+ * channel (static, no handle).
+ */
+OAKAUDIO_API int oakaudio_waveform_sum_samples_s(const float *const *planar,
+ int channel_count, int start_index, int length,
+ oakaudio_min_max *out);
+
+/**
+ * @brief Re-summarize channel-interleaved pairs into one point per
+ * channel (static, no handle).
+ */
+OAKAUDIO_API int oakaudio_waveform_re_sum_s(const oakaudio_min_max *in,
+ int nb_entries, int nb_channels, oakaudio_min_max *out);
+
+/**
+ * @brief Extract a whole-file waveform summary from a media file through
+ * the oakcodec decoder C ABI.
+ *
+ * Decodes `filename`'s audio stream `stream_index` (index within the
+ * file's audio stream list) and reduces it to channel-interleaved
+ * min/max pairs, one point per `samples_per_point` source samples.
+ *
+ * @param out_pairs Receives the pairs; may be NULL to query the size.
+ * @param capacity_points Capacity of out_pairs in points.
+ * @param out_channel_count Receives the channel count (may be NULL).
+ * @return Number of points (>= 0); when out_pairs is NULL or too small,
+ * the required count is returned and nothing is written.
+ * Negative OAKAUDIO_E_* code on failure
+ * (OAKAUDIO_E_NOT_FOUND when the file/stream does not exist).
+ */
+OAKAUDIO_API int oakaudio_waveform_extract(const char *filename,
+ int stream_index, int samples_per_point,
+ oakaudio_min_max *out_pairs, int capacity_points,
+ int *out_channel_count);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif //OAK_EDITOR_AUDIO_WAVEFORM_H
diff --git a/include/common/config.h b/include/common/config.h
new file mode 100644
index 000000000..a6f731785
--- /dev/null
+++ b/include/common/config.h
@@ -0,0 +1,189 @@
+/***
+
+ Oak Video Editor - 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_EDITOR_COMMON_CONFIG_H
+#define OAK_EDITOR_COMMON_CONFIG_H
+
+#include
+
+#include "common/error.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @brief De-Qt application configuration store, C ABI
+ * (M1-oakcommon.md §2.1, extended for the real consumer surface)
+ *
+ * oakcommon_config is a process-wide singleton key/value store (the de-Qt
+ * replacement for engine/config/config.h's QSettings/QVariant wrapper).
+ * Per the config-wave ruling it is NOT wrapped in the refcounted-handle
+ * convention of common/handle.h: there is exactly one store per process,
+ * so the family is a plain set of functions over that singleton (same
+ * singleton precedent as OakCurrent).
+ *
+ * Keys follow the frozen (group, key) convention of §2.1 and keep the
+ * QSettings INI shape: pass group == NULL (or "") for a top-level key,
+ * otherwise the entry is stored under an INI [group] section and
+ * addressed as "group/key" internally.
+ *
+ * Values are typed (string / int64 / double / bool). Rational settings
+ * are stored as strings in the "num/den" form used by
+ * oakcore_rational_to_string(). Typed getters take a fallback which is
+ * returned when the key is absent or has a different type (§2.1 special
+ * convention: they return values, not error codes).
+ *
+ * Persistence is an INI file at
+ * /config.ini. The store
+ * starts up with compiled-in defaults; oakcommon_config_load() re-reads
+ * the file (a missing file is not an error) and oakcommon_config_save()
+ * writes it. The OAK_CONFIG_DIR environment override honored by
+ * get_configuration_location() also redirects this file (tests/tooling).
+ *
+ * NOTE (behavior change): the old Qt implementation persisted to
+ * config.xml (engine XML) — and on macOS QSettings used a plist — so
+ * previously saved settings do NOT carry over; the first run starts from
+ * the compiled-in defaults.
+ */
+
+typedef enum OakCommonConfigEntryType {
+ OAKCOMMON_CONFIG_ENTRY_NONE = 0, /**< No entry / null type. */
+ OAKCOMMON_CONFIG_ENTRY_STRING = 1,
+ OAKCOMMON_CONFIG_ENTRY_INT = 2,
+ OAKCOMMON_CONFIG_ENTRY_DOUBLE = 3,
+ OAKCOMMON_CONFIG_ENTRY_BOOL = 4
+} OakCommonConfigEntryType;
+
+/**
+ * @brief Handler for configuration errors that should be shown to the user
+ *
+ * The engine layer cannot show dialogs itself. The UI registers a handler
+ * (e.g. QMessageBox-based) at startup; without one, errors go to stderr.
+ * Same injection pattern as the codec task-submit callback.
+ */
+typedef void (*OakCommonConfigErrorHandler)(const char *title,
+ const char *message,
+ void *userdata);
+
+/**
+ * @brief Resets the store to compiled-in defaults and loads config.ini
+ *
+ * A missing file leaves the defaults in place and returns OAKCOMMON_OK.
+ * Malformed lines are skipped. An unreadable existing file is reported
+ * through the error handler and returns OAKCOMMON_E_FAILED.
+ */
+int oakcommon_config_load(void);
+
+/**
+ * @brief Writes the current store to config.ini (via a temp file + rename)
+ *
+ * On failure the error handler is invoked and OAKCOMMON_E_FAILED is
+ * returned.
+ */
+int oakcommon_config_save(void);
+
+/**
+ * @brief Resets the store to compiled-in defaults (drops custom keys)
+ */
+int oakcommon_config_reset_defaults(void);
+
+/**
+ * @brief Sets a string entry (§2.1)
+ *
+ * A new key is created as OAKCOMMON_CONFIG_ENTRY_STRING. Setting an
+ * existing typed (INT/DOUBLE/BOOL) entry parses the string into its
+ * declared type; an unparseable value returns OAKCOMMON_E_STATE and
+ * leaves the entry unchanged.
+ */
+void oakcommon_config_set(const char *group, const char *key,
+ const char *value_utf8);
+
+/**
+ * @brief Reads an entry as a string, two-stage buffer (§2.1)
+ *
+ * Numeric/bool entries are formatted (bools as "true"/"false", doubles
+ * with %g).
+ *
+ * @return Required buffer size in bytes (including the terminating NUL),
+ * or a negative OAKCOMMON_E_* error code (OAKCOMMON_E_NOT_FOUND when the
+ * key is absent).
+ */
+int oakcommon_config_get(const char *group, const char *key, char *buf,
+ int buf_size);
+
+/**
+ * @brief Reads an INT entry as int (§2.1)
+ *
+ * @return The stored value, or `fallback` when the key is absent or has
+ * a different type.
+ */
+int oakcommon_config_get_int(const char *group, const char *key,
+ int fallback);
+
+/**
+ * @brief Reads a DOUBLE entry (§2.1), fallback semantics as get_int
+ */
+double oakcommon_config_get_double(const char *group, const char *key,
+ double fallback);
+
+/**
+ * @brief Sets an INT entry (32-bit, §2.1)
+ */
+void oakcommon_config_set_int(const char *group, const char *key, int v);
+
+/**
+ * @brief INT entry as int64 (extension for channel-layout style values)
+ */
+int64_t oakcommon_config_get_int64(const char *group, const char *key,
+ int64_t fallback);
+void oakcommon_config_set_int64(const char *group, const char *key,
+ int64_t v);
+
+/**
+ * @brief BOOL entry as int 0/1 (extension), fallback semantics as get_int
+ */
+int oakcommon_config_get_bool(const char *group, const char *key,
+ int fallback);
+void oakcommon_config_set_bool(const char *group, const char *key, int v);
+
+/**
+ * @brief Sets a DOUBLE entry (extension)
+ */
+void oakcommon_config_set_double(const char *group, const char *key,
+ double v);
+
+/**
+ * @brief Returns the OakCommonConfigEntryType of a key, or a negative
+ * OAKCOMMON_E_* error (OAKCOMMON_E_NOT_FOUND when the key is absent)
+ */
+int oakcommon_config_entry_type(const char *group, const char *key);
+
+/**
+ * @brief Registers (or clears, with NULL) the error handler
+ */
+int oakcommon_config_set_error_handler(OakCommonConfigErrorHandler handler,
+ void *userdata);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // OAK_EDITOR_COMMON_CONFIG_H
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 7b86873b7..ee1407ff5 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -3,3 +3,4 @@ add_subdirectory(undo)
add_subdirectory(node)
add_subdirectory(render)
add_subdirectory(codec)
+add_subdirectory(audio)
diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt
new file mode 100644
index 000000000..f41570121
--- /dev/null
+++ b/src/audio/CMakeLists.txt
@@ -0,0 +1,6 @@
+add_subdirectory(src)
+add_subdirectory(c_api)
+
+if(BUILD_TESTS)
+ add_subdirectory(tests)
+endif()
diff --git a/src/audio/c_api/CMakeLists.txt b/src/audio/c_api/CMakeLists.txt
new file mode 100644
index 000000000..9a597e57d
--- /dev/null
+++ b/src/audio/c_api/CMakeLists.txt
@@ -0,0 +1,24 @@
+# Oak Video Editor - 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 .
+
+target_sources(oakaudio PRIVATE
+ alive.cpp
+ levelmeter.cpp
+ manager.cpp
+ processor.cpp
+ sync.cpp
+ waveform.cpp
+)
diff --git a/src/audio/c_api/alive.cpp b/src/audio/c_api/alive.cpp
new file mode 100644
index 000000000..f6edfbf27
--- /dev/null
+++ b/src/audio/c_api/alive.cpp
@@ -0,0 +1,49 @@
+/***
+
+ Oak Video Editor - 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 .
+
+***/
+
+#include
+
+#include "audio/error.h"
+#include "audio/manager.h"
+
+namespace
+{
+std::atomic g_alive{ 0 };
+}
+
+namespace oakaudio
+{
+
+void alive_inc()
+{
+ g_alive.fetch_add(1, std::memory_order_relaxed);
+}
+
+void alive_dec()
+{
+ g_alive.fetch_sub(1, std::memory_order_relaxed);
+}
+
+}
+
+extern "C" int oakaudio_debug_alive_count(void)
+{
+ return g_alive.load(std::memory_order_relaxed);
+}
diff --git a/src/audio/c_api/levelmeter.cpp b/src/audio/c_api/levelmeter.cpp
new file mode 100644
index 000000000..33d65ab38
--- /dev/null
+++ b/src/audio/c_api/levelmeter.cpp
@@ -0,0 +1,84 @@
+/***
+
+ Oak Video Editor - 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 .
+
+***/
+
+#include "audio/levelmeter.h"
+
+#include
+
+#include "audiolevelmeter.h"
+#include "ffmpeg_bridge/ffmpeg_bridge.h"
+
+using olive::AudioLevelMeter;
+using olive::core::AudioParams;
+using olive::core::Rational;
+using olive::core::SampleBuffer;
+using olive::core::SampleFormat;
+
+extern "C" int oakaudio_levelmeter_analyze(const float *const *planar,
+ int channel_count, int frame_count,
+ oakaudio_channel_stats *channels, int channels_capacity,
+ oakaudio_meter_stats *summary)
+{
+ if (!planar || channel_count <= 0 || frame_count < 0 ||
+ (channels && channels_capacity < channel_count)) {
+ return OAKAUDIO_E_INVALID;
+ }
+ if (!channels && !summary) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ // Repack into a SampleBuffer (planar f32) for the C++ implementation.
+ AudioParams params(48000, fb_channel_layout_default(channel_count),
+ SampleFormat(SampleFormat::f32_p));
+ SampleBuffer buffer(params, Rational(frame_count, 48000));
+ for (int ch = 0; ch < channel_count; ch++) {
+ if (!planar[ch]) {
+ return OAKAUDIO_E_INVALID;
+ }
+ if (frame_count > 0) {
+ memcpy(buffer.data(ch), planar[ch],
+ size_t(frame_count) * sizeof(float));
+ }
+ }
+
+ const AudioLevelMeter::Stats stats =
+ AudioLevelMeter::analyze_sample_buffer(buffer);
+
+ if (channels) {
+ for (int ch = 0; ch < channel_count; ch++) {
+ const AudioLevelMeter::ChannelStats &s =
+ stats.channels[size_t(ch)];
+ oakaudio_channel_stats &dst = channels[ch];
+ dst.peak_linear = s.peak_linear;
+ dst.peak_db = s.peak_db;
+ dst.rms_linear = s.rms_linear;
+ dst.rms_db = s.rms_db;
+ dst.vu_db = s.vu_db;
+ }
+ }
+
+ if (summary) {
+ summary->max_peak_linear = stats.max_peak_linear;
+ summary->integrated_lufs = stats.integrated_lufs;
+ summary->silence = stats.silence ? 1 : 0;
+ }
+
+ return OAKAUDIO_OK;
+}
diff --git a/src/audio/c_api/manager.cpp b/src/audio/c_api/manager.cpp
new file mode 100644
index 000000000..5ac5f9e64
--- /dev/null
+++ b/src/audio/c_api/manager.cpp
@@ -0,0 +1,280 @@
+/***
+
+ Oak Video Editor - 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 .
+
+***/
+
+#include "audio/manager.h"
+
+#include
+
+#include "audiomanager.h"
+
+using olive::AudioManager;
+using olive::core::AudioParams;
+using olive::core::SampleFormat;
+
+namespace
+{
+
+// Singleton semantics (mirrors oakcommon's OakCurrent): the ctx points to
+// the process-wide instance, so addref/release never destroy anything.
+void singleton_addref(void *ctx)
+{
+ (void) ctx;
+}
+
+void singleton_release(void *ctx)
+{
+ (void) ctx;
+}
+
+OakAudioManager wrap(AudioManager *m)
+{
+ OakAudioManager h = {};
+ h.ctx = m;
+ h.addref = &singleton_addref;
+ h.release = &singleton_release;
+ h.abi_version = OAKAUDIO_ABI_VERSION;
+ return h;
+}
+
+AudioManager *impl(OakAudioManager self)
+{
+ return static_cast(self.ctx);
+}
+
+int write_error(const std::string &s, char *buf, int buf_size)
+{
+ if (buf && buf_size > 0) {
+ const int n = std::min(int(s.size()), buf_size - 1);
+ std::memcpy(buf, s.data(), size_t(n));
+ buf[n] = '\0';
+ }
+ return int(s.size()) + 1;
+}
+
+} // namespace
+
+extern "C" int oakaudio_manager_create_instance(void)
+{
+ if (!AudioManager::instance()) {
+ try {
+ AudioManager::create_instance();
+ } catch (...) {
+ return OAKAUDIO_E_NOMEM;
+ }
+ }
+ return AudioManager::instance() ? OAKAUDIO_OK : OAKAUDIO_E_NOMEM;
+}
+
+extern "C" void oakaudio_manager_destroy_instance(void)
+{
+ AudioManager::destroy_instance();
+}
+
+extern "C" OakAudioManager oakaudio_manager_instance(void)
+{
+ return wrap(AudioManager::instance());
+}
+
+extern "C" void oakaudio_manager_free(OakAudioManager *self)
+{
+ // Singleton: releasing never destroys; just clear the caller's copy.
+ if (self) {
+ self->ctx = nullptr;
+ }
+}
+
+extern "C" int oakaudio_manager_set_output_notify_interval(
+ OakAudioManager self, int64_t bytes)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ if (bytes < 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+ m->set_output_notify_interval(bytes);
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_push_to_output(OakAudioManager self,
+ int rate, uint64_t layout, int format,
+ const char *samples, int64_t samples_size,
+ char *error_buf, int error_buf_size)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ if (rate <= 0 || !samples || samples_size < 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ const AudioParams params(rate, layout,
+ SampleFormat(SampleFormat::Format(format)));
+ std::string error;
+ if (!m->push_to_output(params, samples, samples_size, &error)) {
+ if (error_buf && error_buf_size > 0) {
+ write_error(error, error_buf, error_buf_size);
+ }
+ return OAKAUDIO_E_FAILED;
+ }
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_clear_buffered_output(OakAudioManager self)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ m->clear_buffered_output();
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_stop_output(OakAudioManager self)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ m->stop_output();
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_seconds(OakAudioManager self, double *out)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ if (!out) {
+ return OAKAUDIO_E_INVALID;
+ }
+ *out = m->seconds();
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_reset_output_clock(OakAudioManager self)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ m->reset_output_clock();
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_get_output_device(OakAudioManager self)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ return int(m->get_output_device());
+}
+
+extern "C" int oakaudio_manager_set_output_device(OakAudioManager self,
+ int device)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ m->set_output_device(PaDeviceIndex(device));
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_get_input_device(OakAudioManager self)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ return int(m->get_input_device());
+}
+
+extern "C" int oakaudio_manager_set_input_device(OakAudioManager self,
+ int device)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ m->set_input_device(PaDeviceIndex(device));
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_hard_reset(OakAudioManager self)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ m->hard_reset();
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_start_recording(OakAudioManager self,
+ const oakcodec_encoding_params *params,
+ char *error_buf, int error_buf_size)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ if (!params || !params->audio_enabled) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ std::string error;
+ if (!m->start_recording(*params, &error)) {
+ if (error_buf && error_buf_size > 0) {
+ write_error(error, error_buf, error_buf_size);
+ }
+ return OAKAUDIO_E_FAILED;
+ }
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_stop_recording(OakAudioManager self)
+{
+ AudioManager *m = impl(self);
+ if (!m) {
+ return OAKAUDIO_E_STATE;
+ }
+ m->stop_recording();
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_manager_find_config_device_by_name_s(
+ int is_output_device)
+{
+ return int(AudioManager::find_config_device_by_name(is_output_device != 0));
+}
+
+extern "C" int oakaudio_manager_find_device_by_name_s(const char *name,
+ int is_output_device)
+{
+ if (!name) {
+ return OAKAUDIO_E_INVALID;
+ }
+ return int(AudioManager::find_device_by_name(name, is_output_device != 0));
+}
diff --git a/src/audio/c_api/processor.cpp b/src/audio/c_api/processor.cpp
new file mode 100644
index 000000000..021a12be1
--- /dev/null
+++ b/src/audio/c_api/processor.cpp
@@ -0,0 +1,148 @@
+/***
+
+ Oak Video Editor - 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 .
+
+***/
+
+#include "audio/processor.h"
+
+#include
+#include
+
+#include "audioprocessor.h"
+#include "refcounted.h"
+
+using olive::AudioProcessor;
+using olive::core::AudioParams;
+using olive::core::SampleFormat;
+
+extern "C" OakAudioProcessor oakaudio_processor_init(void)
+{
+ return oakaudio::make_handle_in_place();
+}
+
+extern "C" void oakaudio_processor_free(OakAudioProcessor *self)
+{
+ oakaudio::free_handle(self);
+}
+
+extern "C" int oakaudio_processor_open(OakAudioProcessor self,
+ int in_rate, uint64_t in_layout, int in_format,
+ int out_rate, uint64_t out_layout, int out_format, double speed)
+{
+ AudioProcessor *p = oakaudio::handle_impl(self.ctx);
+ if (!p) {
+ return OAKAUDIO_E_INVALID;
+ }
+ if (p->is_open()) {
+ return OAKAUDIO_E_STATE;
+ }
+ if (in_rate <= 0 || out_rate <= 0 || speed <= 0.0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ // The C ABI delivers planar float output only; force the output format
+ // stage to f32p (see OAKAUDIO_PROCESSOR_OUTPUT_FORMAT).
+ if (out_format != OAKAUDIO_PROCESSOR_OUTPUT_FORMAT) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ const AudioParams from(in_rate, in_layout,
+ SampleFormat(SampleFormat::Format(in_format)));
+ const AudioParams to(out_rate, out_layout,
+ SampleFormat(SampleFormat::Format(out_format)));
+
+ return p->open(from, to, speed) ? OAKAUDIO_OK : OAKAUDIO_E_FAILED;
+}
+
+extern "C" int oakaudio_processor_close(OakAudioProcessor self)
+{
+ AudioProcessor *p = oakaudio::handle_impl(self.ctx);
+ if (!p) {
+ return OAKAUDIO_E_INVALID;
+ }
+ p->close();
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_processor_is_open(OakAudioProcessor self)
+{
+ AudioProcessor *p = oakaudio::handle_impl(self.ctx);
+ if (!p) {
+ return OAKAUDIO_E_INVALID;
+ }
+ return p->is_open() ? 1 : 0;
+}
+
+extern "C" int oakaudio_processor_convert(OakAudioProcessor self,
+ const float *const *in_planar, int in_frame_count,
+ float *const *out_planar, int out_capacity_frames)
+{
+ AudioProcessor *p = oakaudio::handle_impl(self.ctx);
+ if (!p) {
+ return OAKAUDIO_E_INVALID;
+ }
+ if (!p->is_open()) {
+ return OAKAUDIO_E_STATE;
+ }
+ if (in_frame_count < 0 || out_capacity_frames < 0 ||
+ (in_frame_count > 0 && !in_planar)) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ const int channels = p->to().channel_count();
+ if (channels <= 0) {
+ return OAKAUDIO_E_STATE;
+ }
+
+ AudioProcessor::Buffer buf;
+ int r = p->convert(const_cast(in_planar), in_frame_count,
+ out_planar ? &buf : nullptr);
+ if (r < 0) {
+ return OAKAUDIO_E_FAILED;
+ }
+
+ if (!out_planar) {
+ return 0;
+ }
+
+ // Output is planar f32 (enforced by open()); each buffer entry is one
+ // channel's float plane.
+ const int out_frames = buf.empty() ? 0 :
+ int(buf[0].size() / sizeof(float));
+ const int frames = std::min(out_frames, out_capacity_frames);
+ for (int ch = 0; ch < channels && ch < int(buf.size()); ch++) {
+ if (out_planar[ch]) {
+ memcpy(out_planar[ch], buf[size_t(ch)].data(),
+ size_t(frames) * sizeof(float));
+ }
+ }
+ return frames;
+}
+
+extern "C" int oakaudio_processor_flush(OakAudioProcessor self)
+{
+ AudioProcessor *p = oakaudio::handle_impl(self.ctx);
+ if (!p) {
+ return OAKAUDIO_E_INVALID;
+ }
+ if (!p->is_open()) {
+ return OAKAUDIO_E_STATE;
+ }
+ p->flush();
+ return OAKAUDIO_OK;
+}
diff --git a/src/audio/c_api/refcounted.h b/src/audio/c_api/refcounted.h
new file mode 100644
index 000000000..f23d7133f
--- /dev/null
+++ b/src/audio/c_api/refcounted.h
@@ -0,0 +1,124 @@
+/***
+
+ Oak Video Editor - 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 OAKAUDIO_C_API_REFCOUNTED_H
+#define OAKAUDIO_C_API_REFCOUNTED_H
+
+#include
+#include
+#include
+#include
+
+#include "audio/error.h"
+
+namespace oakaudio
+{
+
+/**
+ * @brief Heap box behind every handle's ctx pointer.
+ *
+ * Same pattern as oakcodec's c_api/refcounted.h: holds the wrapped
+ * object plus its atomic reference count. addref and release are emitted
+ * per boxed type so that the function pointers stored in a handle always
+ * run code from the DLL that created the object. Every box also
+ * participates in the oakaudio_debug_alive_count() ledger.
+ */
+template struct RefCounted {
+ T impl;
+ std::atomic refs;
+
+ template
+ explicit RefCounted(Args &&...args)
+ : impl(std::forward(args)...)
+ , refs(1)
+ {
+ }
+};
+
+template void ref_counted_addref(void *ctx)
+{
+ auto *box = static_cast *>(ctx);
+ if (box)
+ box->refs.fetch_add(1, std::memory_order_relaxed);
+}
+
+void alive_inc();
+void alive_dec();
+
+template void ref_counted_release(void *ctx)
+{
+ auto *box = static_cast *>(ctx);
+ if (box && box->refs.fetch_sub(1, std::memory_order_acq_rel) == 1) {
+ delete box;
+ alive_dec();
+ }
+}
+
+/**
+ * @brief Build a by-value handle owning a freshly boxed object (count 1).
+ *
+ * On allocation failure the returned handle has ctx == NULL (all C API
+ * functions treat that as OAKAUDIO_E_INVALID and free() as a no-op).
+ */
+template
+Handle make_handle_in_place(Args &&...args)
+{
+ Handle h = {};
+ try {
+ h.ctx = new RefCounted(std::forward(args)...);
+ alive_inc();
+ } catch (...) {
+ h.ctx = nullptr;
+ }
+ h.addref = &ref_counted_addref;
+ h.release = &ref_counted_release;
+ h.abi_version = OAKAUDIO_ABI_VERSION;
+ return h;
+}
+
+template Handle make_handle(T &&value)
+{
+ return make_handle_in_place::type>(
+ std::forward(value));
+}
+
+/**
+ * @brief Recover the boxed object from a handle ctx (NULL-safe).
+ */
+template T *handle_impl(void *ctx)
+{
+ auto *box = static_cast *>(ctx);
+ return box ? &box->impl : nullptr;
+}
+
+/**
+ * @brief Shared free() body: release the ctx, no-op on NULL/empty handle.
+ */
+template void free_handle(Handle *h)
+{
+ if (!h || !h->ctx || !h->release)
+ return;
+ h->release(h->ctx);
+ h->ctx = nullptr;
+}
+
+} // namespace oakaudio
+
+#endif // OAKAUDIO_C_API_REFCOUNTED_H
diff --git a/src/audio/c_api/sync.cpp b/src/audio/c_api/sync.cpp
new file mode 100644
index 000000000..752eee907
--- /dev/null
+++ b/src/audio/c_api/sync.cpp
@@ -0,0 +1,206 @@
+/***
+
+ Oak Video Editor - 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 .
+
+***/
+
+#include "audio/sync.h"
+
+#include
+#include
+
+#include "audiosynchronizer.h"
+#include "audiowaveformsync.h"
+#include "ffmpeg_bridge/ffmpeg_bridge.h"
+
+using olive::AudioSynchronizer;
+using olive::AudioWaveformSync;
+using olive::core::AudioParams;
+using olive::core::Rational;
+using olive::core::SampleBuffer;
+using olive::core::SampleFormat;
+
+namespace
+{
+
+std::vector to_mask(const uint8_t *valid, int len)
+{
+ std::vector mask;
+ if (valid) {
+ mask.resize(size_t(len));
+ for (int i = 0; i < len; i++) {
+ mask[size_t(i)] = valid[i] ? 1 : 0;
+ }
+ }
+ return mask;
+}
+
+} // namespace
+
+extern "C" int oakaudio_sync_extract_rms_envelope(
+ const float *const *planar, int channel_count, int frame_count,
+ uint64_t window_samples, double *out, int capacity)
+{
+ if (!planar || channel_count <= 0 || frame_count < 0 ||
+ !window_samples || capacity < 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ AudioParams params(48000, fb_channel_layout_default(channel_count),
+ SampleFormat(SampleFormat::f32_p));
+ SampleBuffer buffer(params, Rational(frame_count, 48000));
+ for (int ch = 0; ch < channel_count; ch++) {
+ if (!planar[ch]) {
+ return OAKAUDIO_E_INVALID;
+ }
+ if (frame_count > 0) {
+ memcpy(buffer.data(ch), planar[ch],
+ size_t(frame_count) * sizeof(float));
+ }
+ }
+
+ const std::vector envelope =
+ AudioWaveformSync::extract_rms_envelope(buffer, window_samples);
+ const int windows = int(envelope.size());
+ if (!out || capacity < windows) {
+ return windows;
+ }
+ memcpy(out, envelope.data(), size_t(windows) * sizeof(double));
+ return windows;
+}
+
+extern "C" int oakaudio_sync_estimate_envelope_offset(
+ const double *reference, int reference_len,
+ const double *candidate, int candidate_len,
+ const uint8_t *reference_valid, const uint8_t *candidate_valid,
+ uint64_t window_samples, int64_t max_offset_windows,
+ oakaudio_offset_result *out)
+{
+ if (!out || !reference || !candidate || reference_len <= 0 ||
+ candidate_len <= 0 || !window_samples || max_offset_windows < 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ const std::vector ref(reference, reference + reference_len);
+ const std::vector cand(candidate, candidate + candidate_len);
+ const std::vector ref_valid = to_mask(reference_valid, reference_len);
+ const std::vector cand_valid =
+ to_mask(candidate_valid, candidate_len);
+
+ const AudioWaveformSync::OffsetResult r =
+ AudioWaveformSync::estimate_envelope_offset(
+ ref, cand, ref_valid, cand_valid, window_samples,
+ max_offset_windows);
+
+ out->offset_samples = r.offset_samples;
+ out->confidence = r.confidence;
+ out->valid = r.valid ? 1 : 0;
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_sync_estimate_stretch_and_offset(
+ const double *reference, int reference_len,
+ const double *candidate, int candidate_len,
+ const uint8_t *reference_valid, const uint8_t *candidate_valid,
+ uint64_t window_samples, int64_t max_offset_windows,
+ double min_rate, double max_rate, double rate_step,
+ oakaudio_stretch_offset_result *out)
+{
+ if (!out || !reference || !candidate || reference_len <= 0 ||
+ candidate_len <= 0 || !window_samples || max_offset_windows < 0 ||
+ min_rate <= 0.0 || max_rate < min_rate || rate_step <= 0.0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ const std::vector ref(reference, reference + reference_len);
+ const std::vector cand(candidate, candidate + candidate_len);
+ const std::vector ref_valid = to_mask(reference_valid, reference_len);
+ const std::vector cand_valid =
+ to_mask(candidate_valid, candidate_len);
+
+ const AudioWaveformSync::StretchOffsetResult r =
+ AudioWaveformSync::estimate_stretch_and_offset(
+ ref, cand, ref_valid, cand_valid, window_samples,
+ max_offset_windows, min_rate, max_rate, rate_step);
+
+ out->rate = r.rate;
+ out->offset_samples = r.offset_samples;
+ out->confidence = r.confidence;
+ out->valid = r.valid ? 1 : 0;
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_sync_place_by_source_time(
+ const oakaudio_source_clip *reference,
+ const oakaudio_source_clip *candidate,
+ int64_t reference_timeline_in_num, int64_t reference_timeline_in_den,
+ int64_t *out_num, int64_t *out_den, int *out_valid)
+{
+ if (!reference || !candidate || !out_num || !out_den || !out_valid ||
+ reference->source_start_time_den == 0 ||
+ reference->media_in_den == 0 ||
+ candidate->source_start_time_den == 0 ||
+ candidate->media_in_den == 0 || reference_timeline_in_den == 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ AudioSynchronizer::SourceClip ref;
+ ref.source_start_time = Rational(int(reference->source_start_time_num),
+ int(reference->source_start_time_den));
+ ref.media_in = Rational(int(reference->media_in_num),
+ int(reference->media_in_den));
+ ref.has_source_start_time = reference->has_source_start_time != 0;
+
+ AudioSynchronizer::SourceClip cand;
+ cand.source_start_time = Rational(int(candidate->source_start_time_num),
+ int(candidate->source_start_time_den));
+ cand.media_in = Rational(int(candidate->media_in_num),
+ int(candidate->media_in_den));
+ cand.has_source_start_time = candidate->has_source_start_time != 0;
+
+ const AudioSynchronizer::Placement p = AudioSynchronizer::place_by_source_time(
+ ref, cand,
+ Rational(int(reference_timeline_in_num),
+ int(reference_timeline_in_den)));
+
+ *out_num = p.timeline_in.numerator();
+ *out_den = p.timeline_in.denominator();
+ *out_valid = p.valid ? 1 : 0;
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_sync_place_by_waveform_offset(
+ int64_t reference_timeline_in_num, int64_t reference_timeline_in_den,
+ int64_t candidate_offset_samples, int sample_rate,
+ int64_t *out_num, int64_t *out_den, int *out_valid)
+{
+ if (!out_num || !out_den || !out_valid ||
+ reference_timeline_in_den == 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ const AudioSynchronizer::Placement p =
+ AudioSynchronizer::place_by_waveform_offset(
+ Rational(int(reference_timeline_in_num),
+ int(reference_timeline_in_den)),
+ candidate_offset_samples, sample_rate);
+
+ *out_num = p.timeline_in.numerator();
+ *out_den = p.timeline_in.denominator();
+ *out_valid = p.valid ? 1 : 0;
+ return OAKAUDIO_OK;
+}
diff --git a/src/audio/c_api/waveform.cpp b/src/audio/c_api/waveform.cpp
new file mode 100644
index 000000000..c866922ef
--- /dev/null
+++ b/src/audio/c_api/waveform.cpp
@@ -0,0 +1,554 @@
+/***
+
+ Oak Video Editor - 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 .
+
+***/
+
+#include "audio/waveform.h"
+
+#include
+#include
+#include
+
+#include "audiovisualwaveform.h"
+#include "codec/decoder.h"
+#include "ffmpeg_bridge/ffmpeg_bridge.h"
+#include "olive/core/render/samplebuffer.h"
+#include "refcounted.h"
+
+using olive::AudioVisualWaveform;
+using olive::core::AudioParams;
+using olive::core::Rational;
+using olive::core::SampleBuffer;
+using olive::core::SampleFormat;
+
+namespace
+{
+
+AudioVisualWaveform::SamplePerChannel *as_pairs(oakaudio_min_max *p)
+{
+ static_assert(sizeof(oakaudio_min_max) ==
+ sizeof(AudioVisualWaveform::SamplePerChannel),
+ "POD layout mismatch");
+ return reinterpret_cast(p);
+}
+
+const AudioVisualWaveform::SamplePerChannel *
+as_pairs_const(const oakaudio_min_max *p)
+{
+ return reinterpret_cast(p);
+}
+
+bool make_rational(int64_t num, int64_t den, Rational *out)
+{
+ if (den == 0) {
+ return false;
+ }
+ *out = Rational(int(num), int(den));
+ return true;
+}
+
+/* ---- oakaudio_waveform_extract() helpers --------------------------------- */
+
+#define OAKAUDIO_EXTRACT_MAX_CHANNELS 64
+
+using PendingPlanes = std::vector>;
+
+void append_pending(PendingPlanes &pending, FBFrame *frame, int channels,
+ int nb)
+{
+ if (pending.empty()) {
+ pending.resize(size_t(channels));
+ }
+ for (int ch = 0; ch < channels; ch++) {
+ const float *data =
+ reinterpret_cast(fb_frame_get_data(frame, ch));
+ std::vector &plane = pending[size_t(ch)];
+ plane.insert(plane.end(), data, data + nb);
+ }
+}
+
+// Emit one point per samples_per_point pending samples. With `flush`, a
+// trailing partial point is emitted too.
+void emit_points(int channels, int samples_per_point, PendingPlanes &pending,
+ std::vector &points, bool flush)
+{
+ if (pending.empty()) {
+ return;
+ }
+ while (true) {
+ const size_t available = pending[0].size();
+ if (available == 0 ||
+ (!flush && available < size_t(samples_per_point))) {
+ return;
+ }
+ const size_t n = std::min(available, size_t(samples_per_point));
+
+ const size_t point = points.size() / size_t(channels);
+ points.resize(points.size() + size_t(channels));
+ for (int ch = 0; ch < channels; ch++) {
+ std::vector &plane = pending[size_t(ch)];
+ float mn = plane[0];
+ float mx = mn;
+ for (size_t i = 1; i < n; i++) {
+ mn = std::min(mn, plane[i]);
+ mx = std::max(mx, plane[i]);
+ }
+ oakaudio_min_max &dst =
+ points[point * size_t(channels) + size_t(ch)];
+ dst.min = mn;
+ dst.max = mx;
+ plane.erase(plane.begin(), plane.begin() + ptrdiff_t(n));
+ }
+ }
+}
+
+int drain_graph(FBAudioGraph *graph, FBFrame *converted, int channels,
+ int samples_per_point, PendingPlanes &pending,
+ std::vector &points)
+{
+ while (true) {
+ const int pull = fb_audio_graph_pull(graph, converted);
+ if (pull < 0) {
+ return OAKAUDIO_E_FAILED;
+ }
+ if (pull == 0) {
+ return OAKAUDIO_OK;
+ }
+ append_pending(pending, converted, channels,
+ fb_frame_get_nb_samples(converted));
+ emit_points(channels, samples_per_point, pending, points, false);
+ }
+}
+
+void flush_points(int channels, int samples_per_point, PendingPlanes &pending,
+ std::vector &points)
+{
+ emit_points(channels, samples_per_point, pending, points, true);
+}
+
+} // namespace
+
+extern "C" OakAudioWaveform oakaudio_waveform_init(void)
+{
+ return oakaudio::make_handle_in_place();
+}
+
+extern "C" void oakaudio_waveform_free(OakAudioWaveform *self)
+{
+ oakaudio::free_handle(self);
+}
+
+extern "C" int oakaudio_waveform_get_channel_count(OakAudioWaveform self)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ return w->channel_count();
+}
+
+extern "C" int oakaudio_waveform_set_channel_count(OakAudioWaveform self,
+ int channels)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ if (channels < 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+ w->set_channel_count(channels);
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_length(OakAudioWaveform self,
+ int64_t *num, int64_t *den)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ if (!num || !den) {
+ return OAKAUDIO_E_INVALID;
+ }
+ *num = w->length().numerator();
+ *den = w->length().denominator();
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_overwrite_samples(OakAudioWaveform self,
+ const float *const *planar, int frame_count, int sample_rate,
+ int64_t start_num, int64_t start_den)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ Rational start;
+ if (!planar || frame_count <= 0 || sample_rate <= 0 ||
+ !make_rational(start_num, start_den, &start)) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ const int channels = w->channel_count();
+ if (channels <= 0) {
+ return OAKAUDIO_E_STATE;
+ }
+
+ // Repack the caller's planes into a SampleBuffer (planar f32).
+ AudioParams params(sample_rate, fb_channel_layout_default(channels),
+ SampleFormat(SampleFormat::f32_p));
+ SampleBuffer buffer(params, Rational(frame_count, sample_rate));
+ for (int ch = 0; ch < channels; ch++) {
+ if (!planar[ch]) {
+ return OAKAUDIO_E_INVALID;
+ }
+ }
+ for (int ch = 0; ch < channels; ch++) {
+ memcpy(buffer.data(ch), planar[ch],
+ size_t(frame_count) * sizeof(float));
+ }
+
+ w->overwrite_samples(buffer, sample_rate, start);
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_overwrite_sums(OakAudioWaveform self,
+ OakAudioWaveform src,
+ int64_t dest_num, int64_t dest_den,
+ int64_t offset_num, int64_t offset_den,
+ int64_t length_num, int64_t length_den)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ AudioVisualWaveform *other =
+ oakaudio::handle_impl(src.ctx);
+ if (!w || !other) {
+ return OAKAUDIO_E_INVALID;
+ }
+ Rational dest, offset, length;
+ if (!make_rational(dest_num, dest_den, &dest) ||
+ !make_rational(offset_num, offset_den, &offset) ||
+ !make_rational(length_num, length_den, &length)) {
+ return OAKAUDIO_E_INVALID;
+ }
+ w->overwrite_sums(*other, dest, offset, length);
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_overwrite_silence(OakAudioWaveform self,
+ int64_t start_num, int64_t start_den,
+ int64_t length_num, int64_t length_den)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ Rational start, length;
+ if (!make_rational(start_num, start_den, &start) ||
+ !make_rational(length_num, length_den, &length)) {
+ return OAKAUDIO_E_INVALID;
+ }
+ w->overwrite_silence(start, length);
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_trim_in(OakAudioWaveform self,
+ int64_t length_num, int64_t length_den)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ Rational length;
+ if (!make_rational(length_num, length_den, &length)) {
+ return OAKAUDIO_E_INVALID;
+ }
+ w->trim_in(length);
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_resize(OakAudioWaveform self,
+ int64_t length_num, int64_t length_den)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ Rational length;
+ if (!make_rational(length_num, length_den, &length) || length < 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+ w->resize(length);
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_trim_range(OakAudioWaveform self,
+ int64_t in_num, int64_t in_den,
+ int64_t length_num, int64_t length_den)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ Rational in, length;
+ if (!make_rational(in_num, in_den, &in) ||
+ !make_rational(length_num, length_den, &length)) {
+ return OAKAUDIO_E_INVALID;
+ }
+ w->trim_range(in, length);
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_get_summary(OakAudioWaveform self,
+ int64_t start_num, int64_t start_den,
+ int64_t length_num, int64_t length_den,
+ oakaudio_min_max *out_pairs, int capacity_points)
+{
+ AudioVisualWaveform *w =
+ oakaudio::handle_impl(self.ctx);
+ if (!w) {
+ return OAKAUDIO_E_INVALID;
+ }
+ Rational start, length;
+ if (!make_rational(start_num, start_den, &start) ||
+ !make_rational(length_num, length_den, &length) || length <= 0 ||
+ capacity_points < 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ // Points are produced at the length scale: one point per channel per
+ // `length`-sized window covering [start, start+length) — i.e. exactly
+ // one point, matching AudioVisualWaveform::get_summary_from_time().
+ AudioVisualWaveform::Sample summary =
+ w->get_summary_from_time(start, length);
+ const int points = int(summary.size()) /
+ std::max(1, w->channel_count());
+
+ if (!out_pairs || capacity_points < points) {
+ return points;
+ }
+ memcpy(out_pairs, summary.data(),
+ summary.size() * sizeof(oakaudio_min_max));
+ return points;
+}
+
+extern "C" int oakaudio_waveform_sum_samples_s(const float *const *planar,
+ int channel_count, int start_index, int length,
+ oakaudio_min_max *out)
+{
+ if (!planar || !out || channel_count <= 0 || start_index < 0 ||
+ length <= 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ AudioParams params(48000, fb_channel_layout_default(channel_count),
+ SampleFormat(SampleFormat::f32_p));
+ SampleBuffer buffer(params, Rational(length + start_index, 48000));
+ for (int ch = 0; ch < channel_count; ch++) {
+ if (!planar[ch]) {
+ return OAKAUDIO_E_INVALID;
+ }
+ memcpy(buffer.data(ch) + start_index, planar[ch],
+ size_t(length) * sizeof(float));
+ }
+
+ AudioVisualWaveform::Sample summary = AudioVisualWaveform::sum_samples(
+ buffer, size_t(start_index), size_t(length));
+ if (int(summary.size()) < channel_count) {
+ return OAKAUDIO_E_FAILED;
+ }
+ memcpy(out, summary.data(),
+ size_t(channel_count) * sizeof(oakaudio_min_max));
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_re_sum_s(const oakaudio_min_max *in,
+ int nb_entries, int nb_channels, oakaudio_min_max *out)
+{
+ if (!in || !out || nb_entries <= 0 || nb_channels <= 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ AudioVisualWaveform::Sample summary = AudioVisualWaveform::re_sum_samples(
+ as_pairs_const(in), size_t(nb_entries), nb_channels);
+ memcpy(out, summary.data(),
+ size_t(nb_channels) * sizeof(oakaudio_min_max));
+ return OAKAUDIO_OK;
+}
+
+extern "C" int oakaudio_waveform_extract(const char *filename,
+ int stream_index, int samples_per_point,
+ oakaudio_min_max *out_pairs, int capacity_points,
+ int *out_channel_count)
+{
+ if (!filename || stream_index < 0 || samples_per_point <= 0 ||
+ capacity_points < 0) {
+ return OAKAUDIO_E_INVALID;
+ }
+
+ // Probe for the stream's native rate/layout (oakcodec probe is
+ // stateless and does not need a conform)
+ OakDecoder probe = oakcodec_decoder_probe(filename);
+ if (!probe.ctx) {
+ return OAKAUDIO_E_NOT_FOUND;
+ }
+ oakcodec_audio_stream_info info;
+ int r = oakcodec_decoder_probe_get_audio_stream(probe, stream_index,
+ &info);
+ oakcodec_decoder_free(&probe);
+ if (r != OAKCODEC_OK) {
+ return OAKAUDIO_E_NOT_FOUND;
+ }
+ if (info.sample_rate <= 0 || info.channel_count <= 0) {
+ return OAKAUDIO_E_FAILED;
+ }
+
+ // Decode the whole stream through ffmpeg_bridge (fb_decoder +
+ // fb_audio_graph) rather than oakcodec_decoder_decode_audio: the
+ // oakcodec decode path is conform-cache based and cannot decode media
+ // without an existing pcm conform until the task system lands (M8).
+ // The stream is reduced to channel-interleaved min/max points at the
+ // native rate/layout.
+ FBDecoder *decoder = fb_decoder_create();
+ if (!decoder) {
+ return OAKAUDIO_E_NOMEM;
+ }
+ r = fb_decoder_open(decoder, filename, info.stream_index);
+ if (r < 0) {
+ fb_decoder_free(&decoder);
+ return OAKAUDIO_E_FAILED;
+ }
+
+ const int channels = info.channel_count;
+ std::vector points;
+ PendingPlanes pending; // per-channel planar backlog
+
+ // The graph converts the stream's native format to planar float; the
+ // stream info carries the validated sample format/rate/layout (audio
+ // frames do not report a sample format through fb_frame_get_format).
+ FBStreamInfo sinfo;
+ if (fb_decoder_get_stream_info(decoder, &sinfo) < 0 ||
+ sinfo.sample_rate <= 0) {
+ fb_decoder_close(decoder);
+ fb_decoder_free(&decoder);
+ return OAKAUDIO_E_FAILED;
+ }
+
+ FBAudioGraphConfig config;
+ memset(&config, 0, sizeof(config));
+ config.in_sample_rate = sinfo.sample_rate;
+ config.in_channel_layout_mask = sinfo.channel_layout_mask;
+ config.in_sample_format = sinfo.sample_format;
+ config.in_channels = channels;
+ config.out_sample_rate = config.in_sample_rate;
+ config.out_channel_layout_mask = config.in_channel_layout_mask;
+ config.out_sample_format = fb_sample_fmt_fltp;
+ config.out_channels = channels;
+ config.out_is_planar = 1;
+ config.tempo = 1.0;
+
+ FBPacket *packet = fb_packet_alloc();
+ FBFrame *frame = fb_frame_alloc();
+ FBFrame *converted = fb_frame_alloc();
+ FBAudioGraph *graph = fb_audio_graph_create(&config);
+ int result = OAKAUDIO_OK;
+
+ if (!packet || !frame || !converted) {
+ result = OAKAUDIO_E_NOMEM;
+ goto done;
+ }
+ if (!graph) {
+ result = OAKAUDIO_E_FAILED;
+ goto done;
+ }
+
+ while (true) {
+ if (fb_decoder_get_frame(decoder, packet, frame) < 0) {
+ break; // EOF or error: stop decoding
+ }
+
+ // Push the decoded frame (planar pointer array; a packed source is
+ // read from plane 0 by the buffersrc)
+ const uint8_t *planes[OAKAUDIO_EXTRACT_MAX_CHANNELS];
+ for (int ch = 0; ch < channels; ch++) {
+ planes[ch] = fb_frame_get_data(frame, ch);
+ }
+ if (fb_audio_graph_push(graph, planes,
+ fb_frame_get_nb_samples(frame)) < 0) {
+ result = OAKAUDIO_E_FAILED;
+ goto done;
+ }
+
+ if (drain_graph(graph, converted, channels, samples_per_point,
+ pending, points) != OAKAUDIO_OK) {
+ result = OAKAUDIO_E_FAILED;
+ goto done;
+ }
+ }
+
+ // Flush the resampler delay
+ if (graph) {
+ fb_audio_graph_push(graph, nullptr, 0);
+ while (fb_audio_graph_pull(graph, converted) == 1) {
+ append_pending(pending, converted, channels,
+ fb_frame_get_nb_samples(converted));
+ }
+ flush_points(channels, samples_per_point, pending, points);
+ }
+
+done:
+ if (graph) {
+ fb_audio_graph_free(&graph);
+ }
+ if (converted) {
+ fb_frame_free(&converted);
+ }
+ if (frame) {
+ fb_frame_free(&frame);
+ }
+ if (packet) {
+ fb_packet_free(&packet);
+ }
+ fb_decoder_close(decoder);
+ fb_decoder_free(&decoder);
+ if (result != OAKAUDIO_OK) {
+ return result;
+ }
+
+ if (out_channel_count) {
+ *out_channel_count = channels;
+ }
+
+ const int point_count = int(points.size()) / channels;
+ if (!out_pairs || capacity_points < point_count) {
+ return point_count;
+ }
+ memcpy(out_pairs, points.data(),
+ points.size() * sizeof(oakaudio_min_max));
+ return point_count;
+}
diff --git a/src/audio/src/CMakeLists.txt b/src/audio/src/CMakeLists.txt
new file mode 100644
index 000000000..7cf9d4a94
--- /dev/null
+++ b/src/audio/src/CMakeLists.txt
@@ -0,0 +1,71 @@
+# Oak Video Editor - 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 .
+
+add_library(oakaudio SHARED
+ audiolevelmeter.cpp
+ audiolevelmeter.h
+ audiomanager.cpp
+ audiomanager.h
+ audioprocessor.cpp
+ audioprocessor.h
+ audiosynchronizer.cpp
+ audiosynchronizer.h
+ audiovisualwaveform.cpp
+ audiovisualwaveform.h
+ audiowaveformsync.cpp
+ audiowaveformsync.h
+ configbridge.cpp
+ configbridge.h
+ previewaudiodevice.cpp
+ previewaudiodevice.h
+)
+
+# In a full-tree build the repo root is CMAKE_SOURCE_DIR; a standalone
+# build (see src/audio/standalone) sets OAK_REPO_ROOT explicitly.
+if(NOT DEFINED OAK_REPO_ROOT)
+ set(OAK_REPO_ROOT ${CMAKE_SOURCE_DIR})
+endif()
+
+find_package(PortAudio REQUIRED)
+
+target_include_directories(oakaudio PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${OAK_REPO_ROOT}/include
+ ${OAK_REPO_ROOT}/core/include
+ ${OAK_REPO_ROOT}/ffmpeg_bridge/include
+ ${PORTAUDIO_INCLUDE_DIRS}
+)
+
+# 01 §1 rule 5: only the OAKAUDIO_API-marked C functions are exported;
+# audio-internal C++ classes must not leak into the global symbol
+# namespace.
+target_compile_options(oakaudio PRIVATE
+ -fvisibility=hidden
+ -fvisibility-inlines-hidden
+)
+
+# Cross-module access goes through C ABIs only: oakcommon (config,
+# ffmpegutils), oakcodec (encoder for recording, decoder for waveform
+# extraction), olivecore (Rational/AudioParams/SampleBuffer wrappers),
+# ffmpeg_bridge (fb_audio_graph resampler infra, same precedent as
+# oakcommon/oakcodec), PortAudio (output device).
+target_link_libraries(oakaudio PUBLIC
+ oakcommon
+ oakcodec
+ olivecore
+ ffmpeg_bridge
+ ${PORTAUDIO_LIBRARIES}
+)
diff --git a/src/audio/src/audiolevelmeter.cpp b/src/audio/src/audiolevelmeter.cpp
new file mode 100644
index 000000000..dcbdc13f7
--- /dev/null
+++ b/src/audio/src/audiolevelmeter.cpp
@@ -0,0 +1,117 @@
+/***
+
+ 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 .
+
+***/
+
+#include "audiolevelmeter.h"
+
+#include
+#include
+
+namespace olive
+{
+
+// De-Qt note: engine/common/decibel.h pulls in QtGlobal, so the two
+// constants/functions used here are inlined (same math: minimum = -200,
+// from_linear = 20*log10 clamped to minimum on -inf).
+static constexpr double k_decibel_minimum = -200.0;
+
+static double decibel_from_linear(double linear)
+{
+ double v = 20.0 * std::log10(linear);
+ if (std::isinf(v)) {
+ return k_decibel_minimum;
+ }
+ return v;
+}
+
+AudioLevelMeter::Stats
+AudioLevelMeter::analyze_sample_buffer(const core::SampleBuffer &samples)
+{
+ Stats stats;
+
+ const int channel_count = samples.channel_count();
+ const size_t sample_count = samples.sample_count();
+ stats.channels.resize(channel_count);
+
+ if (!channel_count || !sample_count) {
+ return stats;
+ }
+
+ double total_square = 0.0;
+ size_t total_samples = 0;
+
+ for (int channel = 0; channel < channel_count; channel++) {
+ const float *channel_data = samples.data(channel);
+ double peak = 0.0;
+ double square_sum = 0.0;
+
+ for (size_t sample = 0; sample < sample_count; sample++) {
+ const double value = channel_data[sample];
+ const double abs_value = std::abs(value);
+
+ peak = std::max(peak, abs_value);
+ square_sum += value * value;
+ }
+
+ const double mean_square =
+ square_sum / static_cast(sample_count);
+ const double rms = std::sqrt(mean_square);
+
+ ChannelStats channel_stats;
+ channel_stats.peak_linear = peak;
+ channel_stats.peak_db = linear_to_db(peak);
+ channel_stats.rms_linear = rms;
+ channel_stats.rms_db = linear_to_db(rms);
+ channel_stats.vu_db = channel_stats.rms_db;
+ stats.channels[channel] = channel_stats;
+
+ stats.max_peak_linear = std::max(stats.max_peak_linear, peak);
+ total_square += square_sum;
+ total_samples += sample_count;
+ }
+
+ // qFuzzyIsNull(double): |x| < 1e-12
+ stats.silence = std::abs(stats.max_peak_linear) < 1e-12;
+ stats.integrated_lufs =
+ power_to_lufs(total_square / static_cast(total_samples));
+
+ return stats;
+}
+
+double AudioLevelMeter::linear_to_db(double linear)
+{
+ if (linear <= 0.0) {
+ return k_decibel_minimum;
+ }
+
+ return decibel_from_linear(linear);
+}
+
+double AudioLevelMeter::power_to_lufs(double mean_square)
+{
+ if (mean_square <= 0.0) {
+ return k_decibel_minimum;
+ }
+
+ // BS.1770 loudness uses K-weighted mean square. This first pass stores the
+ // compatible unit and can be extended with K-weighting without changing UI.
+ return -0.691 + 10.0 * std::log10(mean_square);
+}
+
+}
diff --git a/src/audio/src/audiolevelmeter.h b/src/audio/src/audiolevelmeter.h
new file mode 100644
index 000000000..8900f79c5
--- /dev/null
+++ b/src/audio/src/audiolevelmeter.h
@@ -0,0 +1,57 @@
+/***
+
+ 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_AUDIOLEVELMETER_H
+#define OAK_AUDIOLEVELMETER_H
+
+#include
+
+#include "olive/core/render/samplebuffer.h"
+
+namespace olive
+{
+
+class AudioLevelMeter {
+public:
+ struct ChannelStats {
+ double peak_linear = 0.0;
+ double peak_db = -200.0;
+ double rms_linear = 0.0;
+ double rms_db = -200.0;
+ double vu_db = -200.0;
+ };
+
+ struct Stats {
+ std::vector channels;
+ double max_peak_linear = 0.0;
+ double integrated_lufs = -200.0;
+ bool silence = true;
+ };
+
+ static Stats analyze_sample_buffer(const core::SampleBuffer &samples);
+
+private:
+ static double linear_to_db(double linear);
+ static double power_to_lufs(double mean_square);
+};
+
+}
+
+#endif // OAK_AUDIOLEVELMETER_H
diff --git a/src/audio/src/audiomanager.cpp b/src/audio/src/audiomanager.cpp
new file mode 100644
index 000000000..3c88b8835
--- /dev/null
+++ b/src/audio/src/audiomanager.cpp
@@ -0,0 +1,523 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2025 mikesolar
+
+ 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 .
+
+***/
+
+#include "audiomanager.h"
+
+#include
+#include
+#include
+
+#ifdef PA_HAS_JACK
+#include
+#endif
+
+#include "configbridge.h"
+
+namespace olive
+{
+
+AudioManager *AudioManager::instance_ = nullptr;
+
+void AudioManager::create_instance()
+{
+ if (instance_ == nullptr) {
+ instance_ = new AudioManager();
+ }
+}
+
+void AudioManager::destroy_instance()
+{
+ delete instance_;
+ instance_ = nullptr;
+}
+
+AudioManager *AudioManager::instance()
+{
+ return instance_;
+}
+
+void AudioManager::set_output_notify_interval(int64_t n)
+{
+ output_buffer_->set_notify_interval(n);
+}
+
+void AudioManager::set_output_notify_callback(std::function callback)
+{
+ output_buffer_->set_notify_callback(std::move(callback));
+}
+
+int output_callback(const void *input, void *output, unsigned long frame_count,
+ const PaStreamCallbackTimeInfo *time_info,
+ PaStreamCallbackFlags status_flags, void *user_data)
+{
+ (void) input;
+ (void) time_info;
+ (void) status_flags;
+
+ PreviewAudioDevice *device = static_cast(user_data);
+
+ int64_t max_read = int64_t(frame_count) * device->bytes_per_frame();
+ int64_t read_count =
+ device->read(reinterpret_cast(output), max_read);
+ if (read_count < max_read) {
+ memset(reinterpret_cast(output) + read_count, 0,
+ size_t(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;
+}
+
+int input_callback(const void *input, void *output, unsigned long frame_count,
+ const PaStreamCallbackTimeInfo *time_info,
+ PaStreamCallbackFlags status_flags, void *user_data)
+{
+ (void) output;
+ (void) time_info;
+ (void) status_flags;
+
+ // The oakcodec encoder write path accepts interleaved float32 only; the
+ // input stream is opened with paFloat32 (see start_recording()).
+ OakEncoder *encoder = static_cast(user_data);
+
+ oakcodec_encoder_write_audio(*encoder,
+ reinterpret_cast(input),
+ int(frame_count));
+
+ return paContinue;
+}
+
+bool AudioManager::push_to_output(const core::AudioParams ¶ms,
+ const char *samples, int64_t samples_size,
+ std::string *error)
+{
+ if (output_device_ == paNoDevice) {
+ if (error)
+ *error = "No output device is set";
+ return false;
+ }
+
+ if (output_params_ != params || output_stream_ == nullptr) {
+ output_params_ = params;
+
+ close_output_stream();
+
+ PaStreamParameters p = get_port_audio_params(params, output_device_);
+
+ // 0 = let PortAudio choose the buffer size
+ const unsigned long frames_per_buffer =
+ (unsigned long) audio_config::output_buffer_size();
+
+ PaError r = Pa_OpenStream(&output_stream_, nullptr, &p,
+ output_params_.sample_rate(),
+ frames_per_buffer, paNoFlag, output_callback,
+ output_buffer_);
+ if (r != paNoError) {
+ // Unhandled error
+ fprintf(stderr,
+ "AudioManager::push_to_output: Pa_OpenStream failed: %s\n",
+ Pa_GetErrorText(r));
+ if (error)
+ *error = Pa_GetErrorText(r);
+ return false;
+ }
+
+ output_buffer_->set_bytes_per_frame(output_params_.samples_to_bytes(1));
+ }
+
+ output_buffer_->write(samples, samples_size);
+
+ if (!Pa_IsStreamActive(output_stream_)) {
+ PaError r = Pa_StartStream(output_stream_);
+ if (r != paNoError) {
+ fprintf(stderr,
+ "AudioManager::push_to_output: Pa_StartStream returned "
+ "%d %s\n",
+ r, Pa_GetErrorText(r));
+ }
+ }
+
+ return true;
+}
+
+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 std::max(0.0, seconds);
+}
+
+void AudioManager::reset_output_clock()
+{
+ output_buffer_->reset_output_frames();
+}
+
+PaSampleFormat AudioManager::get_port_audio_sample_format(core::SampleFormat fmt)
+{
+ switch (fmt) {
+ case core::SampleFormat::u8:
+ case core::SampleFormat::u8_p:
+ return paUInt8;
+ case core::SampleFormat::s16:
+ case core::SampleFormat::s16_p:
+ return paInt16;
+ case core::SampleFormat::s32:
+ case core::SampleFormat::s32_p:
+ return paInt32;
+ case core::SampleFormat::f32:
+ case core::SampleFormat::f32_p:
+ return paFloat32;
+ case core::SampleFormat::s64:
+ case core::SampleFormat::s64_p:
+ case core::SampleFormat::f64:
+ case core::SampleFormat::f64_p:
+ case core::SampleFormat::invalid:
+ case core::SampleFormat::count:
+ break;
+ }
+
+ return 0;
+}
+
+void AudioManager::close_output_stream()
+{
+ if (output_stream_) {
+ if (Pa_IsStreamActive(output_stream_)) {
+ stop_output();
+ }
+ Pa_CloseStream(output_stream_);
+ output_stream_ = nullptr;
+ }
+}
+
+void AudioManager::stop_output()
+{
+ // Abort the stream so playback stops immediately
+ if (output_stream_) {
+ Pa_AbortStream(output_stream_);
+ clear_buffered_output();
+ }
+}
+
+void AudioManager::set_output_device(PaDeviceIndex device)
+{
+ if (device == paNoDevice) {
+ fprintf(stderr, "AudioManager: no output device found\n");
+ } else if (device < 0 || device >= Pa_GetDeviceCount()) {
+ fprintf(stderr, "AudioManager: invalid output audio device index: "
+ "%d\n",
+ device);
+ } else {
+ fprintf(stderr, "AudioManager: setting output audio device to %s\n",
+ Pa_GetDeviceInfo(device)->name);
+ }
+
+ output_device_ = device;
+
+ close_output_stream();
+}
+
+void AudioManager::set_input_device(PaDeviceIndex device)
+{
+ if (device == paNoDevice) {
+ fprintf(stderr, "AudioManager: no input device found\n");
+ } else if (device < 0 || device >= Pa_GetDeviceCount()) {
+ fprintf(stderr, "AudioManager: invalid input audio device index: %d\n",
+ device);
+ } else {
+ fprintf(stderr, "AudioManager: setting input audio device to %s\n",
+ Pa_GetDeviceInfo(device)->name);
+ }
+
+ input_device_ = device;
+}
+
+void AudioManager::hard_reset()
+{
+ close_output_stream();
+ Pa_Terminate();
+ Pa_Initialize();
+}
+
+bool AudioManager::start_recording(const oakcodec_encoding_params ¶ms,
+ std::string *error_str)
+{
+ if (input_device_ == paNoDevice) {
+ return false;
+ }
+
+ input_encoder_ = oakcodec_encoder_init(¶ms);
+ if (!input_encoder_.ctx || oakcodec_encoder_open(input_encoder_) != 0) {
+ fprintf(stderr,
+ "AudioManager: failed to open encoder for recording\n");
+ if (input_encoder_.ctx) {
+ char buf[512];
+ if (oakcodec_encoder_last_error(input_encoder_, buf,
+ int(sizeof(buf))) > 0 &&
+ error_str) {
+ *error_str = buf;
+ }
+ oakcodec_encoder_free(&input_encoder_);
+ }
+ return false;
+ }
+
+ // The oakcodec encoder write path takes interleaved float32; capture in
+ // that format regardless of the target encoding sample format.
+ core::AudioParams stream_params(params.audio_sample_rate,
+ params.audio_channel_layout,
+ core::SampleFormat::f32);
+ PaStreamParameters p =
+ get_port_audio_params(stream_params, input_device_);
+
+ PaError r = Pa_OpenStream(&input_stream_, &p, nullptr,
+ params.audio_sample_rate,
+ paFramesPerBufferUnspecified, paNoFlag,
+ input_callback, &input_encoder_);
+ if (r == paNoError) {
+ r = Pa_StartStream(input_stream_);
+ if (r == paNoError) {
+ return true;
+ }
+ }
+
+ if (error_str) {
+ *error_str = Pa_GetErrorText(r);
+ }
+
+ stop_recording();
+ return false;
+}
+
+void AudioManager::stop_recording()
+{
+ if (input_stream_) {
+ if (Pa_IsStreamActive(input_stream_)) {
+ Pa_StopStream(input_stream_);
+ }
+ Pa_CloseStream(input_stream_);
+
+ input_stream_ = nullptr;
+ }
+
+ if (input_encoder_.ctx) {
+ oakcodec_encoder_flush(input_encoder_);
+ oakcodec_encoder_free(&input_encoder_);
+ }
+}
+
+#ifdef __linux__
+static bool str_contains_ci(const char *haystack, const char *needle)
+{
+ const size_t needle_len = strlen(needle);
+ if (!needle_len) {
+ return true;
+ }
+ for (const char *p = haystack; *p; p++) {
+ if (strncasecmp(p, needle, needle_len) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool is_preferred_linux_audio_host_api(const PaHostApiInfo *info)
+{
+ if (!info) {
+ return false;
+ }
+
+ return str_contains_ci(info->name, "PipeWire") ||
+ str_contains_ci(info->name, "JACK") ||
+ str_contains_ci(info->name, "PulseAudio");
+}
+
+static PaDeviceIndex get_preferred_linux_audio_device(bool is_output_device)
+{
+ // Prefer sound servers that provide mixing and desktop integration
+ // (PipeWire, JACK, PulseAudio) over plain ALSA defaults, which often
+ // fail to share the device on modern Linux desktops.
+ static const char *const preferred_host_apis[] = {
+ "PipeWire",
+ "JACK",
+ "PulseAudio",
+ };
+
+ for (const char *preferred : preferred_host_apis) {
+ for (PaHostApiIndex i = 0, end = Pa_GetHostApiCount(); i < end; i++) {
+ const PaHostApiInfo *info = Pa_GetHostApiInfo(i);
+ if (!info) {
+ continue;
+ }
+
+ if (str_contains_ci(info->name, preferred)) {
+ PaDeviceIndex dev = is_output_device ? info->defaultOutputDevice :
+ info->defaultInputDevice;
+ if (dev != paNoDevice) {
+ return dev;
+ }
+ }
+ }
+ }
+
+ return is_output_device ? Pa_GetDefaultOutputDevice() :
+ Pa_GetDefaultInputDevice();
+}
+#endif
+
+PaDeviceIndex AudioManager::find_config_device_by_name(bool is_output_device)
+{
+ return find_device_by_name(
+ audio_config::device_name(is_output_device), is_output_device);
+}
+
+PaDeviceIndex AudioManager::find_device_by_name(const std::string &s,
+ bool is_output_device)
+{
+ PaDeviceIndex exact_match = paNoDevice;
+
+ if (!s.empty()) {
+ for (PaDeviceIndex i = 0, end = Pa_GetDeviceCount(); i < end; i++) {
+ const PaDeviceInfo *device = Pa_GetDeviceInfo(i);
+ if (!device) {
+ continue;
+ }
+
+ if (((is_output_device && device->maxOutputChannels) ||
+ (!is_output_device && device->maxInputChannels)) &&
+ s == device->name) {
+ exact_match = i;
+ break;
+ }
+ }
+ }
+
+#ifdef __linux__
+ // Even if the user/config picked a device by name, upgrade to a preferred
+ // host API (PipeWire/JACK/PulseAudio) when one is available. This avoids
+ // getting stuck on an ALSA device that cannot share the hardware.
+ if (exact_match != paNoDevice) {
+ const PaDeviceInfo *matched_info = Pa_GetDeviceInfo(exact_match);
+ if (matched_info) {
+ const PaHostApiInfo *host_api =
+ Pa_GetHostApiInfo(matched_info->hostApi);
+ if (is_preferred_linux_audio_host_api(host_api)) {
+ // Keep an explicit choice that already uses a preferred API.
+ return exact_match;
+ }
+
+ // Upgrade a non-preferred (e.g. ALSA) match to a preferred backend
+ // when one is available.
+ PaDeviceIndex preferred =
+ get_preferred_linux_audio_device(is_output_device);
+ if (preferred != paNoDevice) {
+ return preferred;
+ }
+
+ // No preferred backend available; keep the saved device.
+ return exact_match;
+ }
+ }
+
+ return get_preferred_linux_audio_device(is_output_device);
+#else
+ if (exact_match != paNoDevice) {
+ return exact_match;
+ }
+
+ return is_output_device ? Pa_GetDefaultOutputDevice() :
+ Pa_GetDefaultInputDevice();
+#endif
+}
+
+PaStreamParameters AudioManager::get_port_audio_params(const core::AudioParams ¶ms,
+ PaDeviceIndex device)
+{
+ PaStreamParameters p;
+
+ p.channelCount = params.channel_count();
+ p.device = device;
+ p.hostApiSpecificStreamInfo = nullptr;
+ p.sampleFormat = get_port_audio_sample_format(params.format());
+
+ if (device >= 0 && device < Pa_GetDeviceCount()) {
+ p.suggestedLatency = Pa_GetDeviceInfo(device)->defaultLowOutputLatency;
+ } else {
+ p.suggestedLatency = 0;
+ }
+
+ return p;
+}
+
+AudioManager::AudioManager()
+ : output_stream_(nullptr)
+ , input_stream_(nullptr)
+{
+ input_encoder_.ctx = nullptr;
+ input_encoder_.addref = nullptr;
+ input_encoder_.release = nullptr;
+ input_encoder_.abi_version = 0;
+
+#ifdef PA_HAS_JACK
+ // PortAudio doesn't do a strcpy, so we need a const char that's readily accessible
+ PaJack_SetClientName("Oak Video Editor");
+#endif
+
+ Pa_Initialize();
+
+ // Get device from config
+ PaDeviceIndex output_device = find_config_device_by_name(true);
+ PaDeviceIndex input_device = find_config_device_by_name(false);
+
+ set_output_device(output_device);
+ set_input_device(input_device);
+
+ output_buffer_ = new PreviewAudioDevice();
+}
+
+AudioManager::~AudioManager()
+{
+ close_output_stream();
+
+ delete output_buffer_;
+
+ Pa_Terminate();
+}
+
+}
diff --git a/src/audio/src/audiomanager.h b/src/audio/src/audiomanager.h
new file mode 100644
index 000000000..cca77a211
--- /dev/null
+++ b/src/audio/src/audiomanager.h
@@ -0,0 +1,142 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2025 mikesolar
+
+ 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_AUDIOMANAGER_H
+#define OAK_AUDIOMANAGER_H
+
+#include
+#include
+#include
+
+#include
+
+#include "codec/encoder.h"
+#include "olive/core/render/audioparams.h"
+#include "previewaudiodevice.h"
+
+namespace olive
+{
+
+/**
+ * @brief Audio input and output management class
+ *
+ * Wraps a PortAudio output stream and a PreviewAudioDevice pull buffer,
+ * exposing audio functionality to the rest of the system.
+ *
+ * De-Qt notes:
+ * - No longer a QObject and no longer inherits PlaybackAudioClock (the
+ * clock interface lives in engine/common, which is not split); the
+ * seconds() method is kept with the same semantics.
+ * - The output_params_changed / output_notify signals are gone; the
+ * notify-interval pulse is delivered through an optional
+ * std::function (set_output_notify_callback) instead.
+ * - Recording goes through the oakcodec encoder C ABI (OakEncoder)
+ * instead of the FFmpegEncoder C++ class; the input stream is always
+ * captured as interleaved float32.
+ */
+class AudioManager {
+public:
+ static void create_instance();
+ static void destroy_instance();
+
+ static AudioManager *instance();
+
+ void set_output_notify_interval(int64_t n);
+
+ /**
+ * @brief Optional callback fired when a notify interval boundary is
+ * crossed (called from the PortAudio callback thread)
+ */
+ void set_output_notify_callback(std::function callback);
+
+ bool push_to_output(const core::AudioParams ¶ms, const char *samples,
+ int64_t samples_size, std::string *error = nullptr);
+
+ void clear_buffered_output();
+
+ 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.
+ */
+ double seconds() const;
+
+ /**
+ * @brief Restarts the output clock at zero for a new playback run
+ */
+ void reset_output_clock();
+
+ PaDeviceIndex get_output_device() const
+ {
+ return output_device_;
+ }
+
+ PaDeviceIndex get_input_device() const
+ {
+ return input_device_;
+ }
+
+ void set_output_device(PaDeviceIndex device);
+
+ void set_input_device(PaDeviceIndex device);
+
+ void hard_reset();
+
+ bool start_recording(const oakcodec_encoding_params ¶ms,
+ std::string *error_str = nullptr);
+
+ void stop_recording();
+
+ static PaDeviceIndex find_config_device_by_name(bool is_output_device);
+ static PaDeviceIndex find_device_by_name(const std::string &s,
+ bool is_output_device);
+
+ static PaStreamParameters get_port_audio_params(const core::AudioParams &p,
+ PaDeviceIndex device);
+
+private:
+ AudioManager();
+
+ ~AudioManager();
+
+ static PaSampleFormat get_port_audio_sample_format(core::SampleFormat fmt);
+
+ void close_output_stream();
+
+ static AudioManager *instance_;
+
+ PaDeviceIndex output_device_;
+ PaStream *output_stream_;
+ core::AudioParams output_params_;
+ PreviewAudioDevice *output_buffer_;
+
+ PaDeviceIndex input_device_;
+ PaStream *input_stream_;
+
+ OakEncoder input_encoder_;
+};
+
+}
+
+#endif // OAK_AUDIOMANAGER_H
diff --git a/src/audio/src/audioprocessor.cpp b/src/audio/src/audioprocessor.cpp
new file mode 100644
index 000000000..a4a4afecf
--- /dev/null
+++ b/src/audio/src/audioprocessor.cpp
@@ -0,0 +1,218 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2025 mikesolar
+
+ 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 .
+
+***/
+
+#include "audioprocessor.h"
+
+#include
+#include
+
+#include "common/ffmpegutils.h"
+
+namespace olive
+{
+
+/**
+ * @brief Bridge sample format for a native format via the oakcommon C ABI
+ */
+static int to_bridge_sample_format(core::SampleFormat fmt)
+{
+ int out = -1; /* fb_sample_fmt_none */
+ oakcommon_ffmpegutils_get_ffmpeg_sample_format(int(fmt), &out);
+ return out;
+}
+
+/**
+ * @brief Ensure an AudioParams has a usable channel layout mask.
+ *
+ * The bridge's abuffer/aformat filters reject a channel layout mask of 0
+ * (e.g. when the user config or a source stream reports a mask of 0).
+ * If the mask is zero, fall back to a default layout derived from the
+ * channel count (stereo when unknown).
+ */
+static core::AudioParams fix_channel_layout(const core::AudioParams ¶ms)
+{
+ core::AudioParams result = params;
+
+ if (params.channel_layout() == 0) {
+ int channels = params.channel_count();
+ if (channels <= 0) {
+ channels = 2;
+ }
+
+ fprintf(stderr,
+ "AudioProcessor: fixing unspecified channel layout "
+ "(channels=%d) -> default %d channel layout\n",
+ params.channel_count(), channels);
+
+ result.set_channel_layout(fb_channel_layout_default(channels));
+ }
+
+ return result;
+}
+
+AudioProcessor::AudioProcessor()
+{
+ graph_ = nullptr;
+ out_frame_ = nullptr;
+}
+
+AudioProcessor::~AudioProcessor()
+{
+ close();
+}
+
+bool AudioProcessor::open(const core::AudioParams &from,
+ const core::AudioParams &to, double tempo)
+{
+ if (graph_) {
+ fprintf(stderr,
+ "AudioProcessor: tried to open a processor that was "
+ "already open\n");
+ return false;
+ }
+
+ core::AudioParams from_fixed = fix_channel_layout(from);
+ core::AudioParams to_fixed = fix_channel_layout(to);
+
+ FBAudioGraphConfig config;
+ memset(&config, 0, sizeof(config));
+ config.in_sample_rate = from_fixed.sample_rate();
+ config.in_channel_layout_mask = from_fixed.channel_layout();
+ config.in_sample_format = to_bridge_sample_format(from_fixed.format());
+ config.in_channels = from_fixed.channel_count();
+
+ config.out_sample_rate = to_fixed.sample_rate();
+ config.out_channel_layout_mask = to_fixed.channel_layout();
+ config.out_sample_format = to_bridge_sample_format(to_fixed.format());
+ config.out_channels = to_fixed.channel_count();
+ config.out_is_planar = to_fixed.format().is_planar() ? 1 : 0;
+
+ config.tempo = tempo;
+
+ graph_ = fb_audio_graph_create(&config);
+ if (!graph_) {
+ fprintf(stderr, "AudioProcessor: failed to create audio filter "
+ "graph\n");
+ return false;
+ }
+
+ out_frame_ = fb_frame_alloc();
+ if (!out_frame_) {
+ fprintf(stderr, "AudioProcessor: failed to allocate output frame\n");
+ close();
+ return false;
+ }
+
+ from_ = from_fixed;
+ to_ = to_fixed;
+
+ return true;
+}
+
+void AudioProcessor::close()
+{
+ if (graph_) {
+ fb_audio_graph_free(&graph_);
+ }
+
+ if (out_frame_) {
+ fb_frame_free(&out_frame_);
+ }
+}
+
+int AudioProcessor::convert(float **in, int nb_in_samples,
+ AudioProcessor::Buffer *output)
+{
+ if (!is_open()) {
+ fprintf(stderr,
+ "AudioProcessor: tried to convert on closed processor\n");
+ return -1;
+ }
+
+ int r = 0;
+
+ if (in && nb_in_samples) {
+ r = fb_audio_graph_push(
+ graph_, reinterpret_cast(in),
+ nb_in_samples);
+ if (r < 0) {
+ fprintf(stderr,
+ "AudioProcessor: failed to add frame to buffersrc: %d\n",
+ r);
+ return r;
+ }
+ }
+
+ if (output) {
+ int nb_channels = to_.channel_count();
+
+ if (to_.format().is_packed()) {
+ nb_channels = 1;
+ }
+
+ AudioProcessor::Buffer &result = *output;
+ result.resize(size_t(nb_channels));
+
+ int byte_offset = 0;
+
+ while (true) {
+ r = fb_audio_graph_pull(graph_, out_frame_);
+ if (r <= 0) {
+ if (r == 0) {
+ // No more output available right now
+ r = 0;
+ } else {
+ // Handle unexpected error
+ fprintf(stderr,
+ "AudioProcessor: failed to pull from "
+ "buffersink: %d\n",
+ r);
+ }
+ break;
+ }
+
+ int nb_bytes = fb_frame_get_nb_samples(out_frame_) *
+ to_.bytes_per_sample_per_channel();
+ if (to_.format().is_packed()) {
+ nb_bytes *= to_.channel_count();
+ }
+
+ for (int i = 0; i < nb_channels; i++) {
+ result[size_t(i)].resize(size_t(byte_offset + nb_bytes));
+ memcpy(result[size_t(i)].data() + byte_offset,
+ fb_frame_get_data(out_frame_, i), size_t(nb_bytes));
+ }
+ byte_offset += nb_bytes;
+ }
+ }
+
+ return r;
+}
+
+void AudioProcessor::flush()
+{
+ int r = fb_audio_graph_push(graph_, nullptr, 0);
+ if (r < 0) {
+ fprintf(stderr, "AudioProcessor: failed to flush: %d\n", r);
+ }
+}
+
+}
diff --git a/src/audio/src/audioprocessor.h b/src/audio/src/audioprocessor.h
new file mode 100644
index 000000000..132108f97
--- /dev/null
+++ b/src/audio/src/audioprocessor.h
@@ -0,0 +1,80 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2025 mikesolar
+
+ 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_AUDIOPROCESSOR_H
+#define OAK_AUDIOPROCESSOR_H
+
+#include
+#include
+
+#include
+
+#include "olive/core/render/audioparams.h"
+
+namespace olive
+{
+
+class AudioProcessor {
+public:
+ AudioProcessor();
+
+ ~AudioProcessor();
+
+ AudioProcessor(const AudioProcessor &) = delete;
+ AudioProcessor &operator=(const AudioProcessor &) = delete;
+
+ bool open(const core::AudioParams &from, const core::AudioParams &to,
+ double tempo = 1.0);
+
+ void close();
+
+ bool is_open() const
+ {
+ return graph_;
+ }
+
+ using Buffer = std::vector>;
+ int convert(float **in, int nb_in_samples, AudioProcessor::Buffer *output);
+
+ void flush();
+
+ const core::AudioParams &from() const
+ {
+ return from_;
+ }
+ const core::AudioParams &to() const
+ {
+ return to_;
+ }
+
+private:
+ FBAudioGraph *graph_;
+
+ core::AudioParams from_;
+
+ core::AudioParams to_;
+
+ FBFrame *out_frame_;
+};
+
+}
+
+#endif // OAK_AUDIOPROCESSOR_H
diff --git a/src/audio/src/audiosynchronizer.cpp b/src/audio/src/audiosynchronizer.cpp
new file mode 100644
index 000000000..c5c81443a
--- /dev/null
+++ b/src/audio/src/audiosynchronizer.cpp
@@ -0,0 +1,65 @@
+/***
+
+ 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 .
+
+***/
+
+#include "audiosynchronizer.h"
+
+namespace olive
+{
+
+AudioSynchronizer::Placement AudioSynchronizer::place_by_source_time(
+ const SourceClip &reference, const SourceClip &candidate,
+ const core::Rational &reference_timeline_in)
+{
+ Placement placement;
+ if (!reference.has_source_start_time || !candidate.has_source_start_time ||
+ reference.source_start_time.isNaN() ||
+ candidate.source_start_time.isNaN()) {
+ return placement;
+ }
+
+ const core::Rational reference_head_source =
+ reference.source_start_time + reference.media_in;
+ const core::Rational candidate_head_source =
+ candidate.source_start_time + candidate.media_in;
+
+ placement.timeline_in =
+ reference_timeline_in + candidate_head_source - reference_head_source;
+ placement.valid = !placement.timeline_in.isNaN();
+ return placement;
+}
+
+AudioSynchronizer::Placement AudioSynchronizer::place_by_waveform_offset(
+ const core::Rational &reference_timeline_in,
+ int64_t candidate_offset_samples, int sample_rate)
+{
+ Placement placement;
+ if (sample_rate <= 0) {
+ return placement;
+ }
+
+ placement.timeline_in = reference_timeline_in +
+ core::Rational::from_double(
+ static_cast(candidate_offset_samples) /
+ static_cast(sample_rate));
+ placement.valid = !placement.timeline_in.isNaN();
+ return placement;
+}
+
+}
diff --git a/src/audio/src/audiosynchronizer.h b/src/audio/src/audiosynchronizer.h
new file mode 100644
index 000000000..96d5a0626
--- /dev/null
+++ b/src/audio/src/audiosynchronizer.h
@@ -0,0 +1,55 @@
+/***
+
+ 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_AUDIOSYNCHRONIZER_H
+#define OAK_AUDIOSYNCHRONIZER_H
+
+#include
+
+#include "olive/core/util/rational.h"
+
+namespace olive
+{
+
+class AudioSynchronizer {
+public:
+ struct SourceClip {
+ core::Rational source_start_time;
+ core::Rational media_in;
+ bool has_source_start_time = false;
+ };
+
+ struct Placement {
+ core::Rational timeline_in;
+ bool valid = false;
+ };
+
+ static Placement
+ place_by_source_time(const SourceClip &reference, const SourceClip &candidate,
+ const core::Rational &reference_timeline_in);
+
+ static Placement
+ place_by_waveform_offset(const core::Rational &reference_timeline_in,
+ int64_t candidate_offset_samples, int sample_rate);
+};
+
+}
+
+#endif // OAK_AUDIOSYNCHRONIZER_H
diff --git a/src/audio/src/audiovisualwaveform.cpp b/src/audio/src/audiovisualwaveform.cpp
new file mode 100644
index 000000000..f789a412f
--- /dev/null
+++ b/src/audio/src/audiovisualwaveform.cpp
@@ -0,0 +1,478 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2025 mikesolar
+
+ 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 .
+
+***/
+
+#include "audiovisualwaveform.h"
+
+#include
+#include
+#include
+#include
+
+#include "olive/core/util/cpuoptimize.h"
+
+namespace olive
+{
+
+using core::Rational;
+
+const Rational AudioVisualWaveform::k_minimum_sample_rate = Rational(1, 8);
+const Rational AudioVisualWaveform::k_maximum_sample_rate = 1024;
+
+AudioVisualWaveform::AudioVisualWaveform()
+ : channels_(0)
+{
+ for (Rational i = k_minimum_sample_rate; i <= k_maximum_sample_rate; i *= 2) {
+ mipmapped_data_.insert({ i, Sample() });
+ }
+}
+
+void AudioVisualWaveform::overwrite_samples_from_buffer(
+ const core::SampleBuffer &samples, int sample_rate, const Rational &start,
+ double target_rate, Sample &data, size_t &start_index,
+ size_t &samples_length)
+{
+ start_index = time_to_samples(start, target_rate);
+ samples_length =
+ time_to_samples(static_cast(samples.sample_count()) /
+ static_cast(sample_rate),
+ target_rate);
+
+ size_t end_index = start_index + samples_length;
+ if (data.size() < end_index) {
+ data.resize(end_index);
+ }
+
+ double chunk_size = double(sample_rate) / double(target_rate);
+
+ for (size_t i = 0; i < samples_length; i += channels_) {
+ size_t src_start = size_t(std::llround(double(i) * chunk_size)) / channels_;
+ size_t src_end = std::min(
+ size_t(std::llround(double(i + channels_) * chunk_size)) / channels_,
+ samples.sample_count());
+
+ Sample summary = sum_samples(samples, src_start, src_end - src_start);
+
+ memcpy(&data.data()[i + start_index], summary.data(),
+ summary.size() * sizeof(SamplePerChannel));
+ }
+}
+
+void AudioVisualWaveform::overwrite_samples_from_mipmap(
+ const AudioVisualWaveform::Sample &input, double input_sample_rate,
+ size_t &input_start, size_t &input_length, const Rational &start,
+ double output_rate, AudioVisualWaveform::Sample &output_data)
+{
+ size_t start_index = time_to_samples(start, output_rate);
+ size_t samples_length = time_to_samples(
+ static_cast(input_length / channels_) / input_sample_rate,
+ output_rate);
+
+ size_t end_index = start_index + samples_length;
+ if (output_data.size() < end_index) {
+ output_data.resize(end_index);
+ }
+
+ // We guarantee mipmaps are powers of two so integer division should be perfectly accurate here
+ size_t chunk_size = size_t(input_sample_rate / output_rate);
+
+ for (size_t i = 0; i < samples_length; i += channels_) {
+ Sample summary =
+ re_sum_samples(&input.data()[input_start + (i * chunk_size)],
+ chunk_size * channels_, channels_);
+
+ memcpy(&output_data.data()[i + start_index], summary.data(),
+ summary.size() * sizeof(SamplePerChannel));
+ }
+
+ input_start = start_index;
+ input_length = samples_length;
+}
+
+void AudioVisualWaveform::validate_virtual_start(const Rational &new_start)
+{
+ if (length_ == 0) {
+ virtual_start_ = new_start;
+ } else if (virtual_start_ > new_start) {
+ trim_in(new_start - virtual_start_);
+ }
+}
+
+void AudioVisualWaveform::overwrite_samples(const core::SampleBuffer &samples,
+ int sample_rate,
+ const Rational &start)
+{
+ if (!channels_) {
+ fprintf(stderr,
+ "AudioVisualWaveform: failed to write samples - channel "
+ "count is zero\n");
+ return;
+ }
+
+ validate_virtual_start(start);
+
+ // Process the largest mipmap directly for the samples
+ auto current_mipmap = mipmapped_data_.rbegin();
+ size_t input_start, input_length;
+ overwrite_samples_from_buffer(samples, sample_rate, start - virtual_start_,
+ current_mipmap->first.to_double(),
+ current_mipmap->second, input_start,
+ input_length);
+
+ while (true) {
+ // For each smaller mipmap, we just process from the mipmap before it, making each one
+ // exponentially faster to create
+ auto previous_mipmap = current_mipmap;
+ current_mipmap++;
+ if (current_mipmap == mipmapped_data_.rend()) {
+ break;
+ }
+
+ overwrite_samples_from_mipmap(
+ previous_mipmap->second, previous_mipmap->first.to_double(),
+ input_start, input_length, start - virtual_start_,
+ current_mipmap->first.to_double(), current_mipmap->second);
+ }
+
+ Rational sample_length(int64_t(samples.sample_count()), sample_rate);
+ length_ = std::max(length_, start + sample_length);
+}
+
+void AudioVisualWaveform::overwrite_sums(const AudioVisualWaveform &sums,
+ const Rational &dest,
+ const Rational &offset,
+ const Rational &length)
+{
+ validate_virtual_start(dest);
+
+ for (auto it = mipmapped_data_.begin(); it != mipmapped_data_.end(); it++) {
+ Rational rate = it->first;
+
+ Sample &our_arr = it->second;
+ const Sample &their_arr = sums.mipmapped_data_.at(rate);
+
+ double rate_dbl = rate.to_double();
+
+ // Get our destination sample
+ size_t our_start_index =
+ time_to_samples(dest - virtual_start_, rate_dbl);
+
+ // Get our source sample, indexing with the SOURCE's channel count
+ size_t their_start_index = size_t(std::floor(offset.to_double() * rate_dbl)) *
+ size_t(sums.channel_count());
+ if (their_start_index >= their_arr.size()) {
+ continue;
+ }
+
+ // Determine how much we're copying
+ size_t copy_len = their_arr.size() - their_start_index;
+ if (!length.isNull()) {
+ copy_len = std::min(copy_len, time_to_samples(length, rate_dbl));
+ if (copy_len == 0) {
+ continue;
+ }
+ }
+
+ // Determine end index of our array
+ size_t end_index = our_start_index + copy_len;
+ if (our_arr.size() < end_index) {
+ our_arr.resize(end_index);
+ }
+
+ memcpy(reinterpret_cast(our_arr.data()) +
+ our_start_index * sizeof(SamplePerChannel),
+ reinterpret_cast(their_arr.data()) +
+ their_start_index * sizeof(SamplePerChannel),
+ copy_len * sizeof(SamplePerChannel));
+ }
+
+ length_ = std::max(length_, dest + ((length.isNull()) ? sums.length() - offset :
+ length));
+}
+
+void AudioVisualWaveform::overwrite_silence(const Rational &start,
+ const Rational &length)
+{
+ validate_virtual_start(start);
+
+ for (auto it = mipmapped_data_.begin(); it != mipmapped_data_.end(); it++) {
+ Rational rate = it->first;
+
+ Sample &our_arr = it->second;
+
+ double rate_dbl = rate.to_double();
+
+ // Get our destination sample
+ size_t our_start_index =
+ time_to_samples(start - virtual_start_, rate_dbl);
+ size_t our_length_index = time_to_samples(length, rate_dbl);
+ size_t our_end_index = our_start_index + our_length_index;
+
+ if (our_arr.size() < our_end_index) {
+ our_arr.resize(our_end_index);
+ }
+
+ memset(reinterpret_cast(our_arr.data()) +
+ our_start_index * sizeof(SamplePerChannel),
+ 0, our_length_index * sizeof(SamplePerChannel));
+ }
+
+ length_ = std::max(length_, start + length);
+}
+
+void AudioVisualWaveform::trim_in(Rational length)
+{
+ if (length == 0) {
+ return;
+ }
+
+ virtual_start_ += length;
+
+ bool negative = (length < 0);
+ if (negative) {
+ length = -length;
+ }
+
+ for (auto it = mipmapped_data_.begin(); it != mipmapped_data_.end(); it++) {
+ Rational rate = it->first;
+ double rate_dbl = rate.to_double();
+ Sample &data = it->second;
+
+ size_t chop_length = time_to_samples(length, rate_dbl);
+ if (chop_length == 0) {
+ continue;
+ }
+
+ if (!negative) {
+ data = Sample(data.begin() + chop_length, data.end());
+ } else {
+ data.insert(data.begin(), chop_length, SamplePerChannel());
+ }
+ }
+
+ if (!negative) {
+ length_ = std::max(Rational(0), length_ - length);
+ }
+ // Prepending grows the data before the existing start, so the absolute
+ // end (which length_ tracks) is unchanged
+}
+
+AudioVisualWaveform AudioVisualWaveform::mid(const Rational &offset) const
+{
+ AudioVisualWaveform mid = *this;
+
+ mid.trim_in(offset - virtual_start_);
+
+ return mid;
+}
+
+AudioVisualWaveform AudioVisualWaveform::mid(const Rational &offset,
+ const Rational &length) const
+{
+ AudioVisualWaveform mid = *this;
+
+ mid.trim_range(offset - virtual_start_, length);
+
+ return mid;
+}
+
+void AudioVisualWaveform::resize(const Rational &length)
+{
+ if (length_ == length) {
+ return;
+ }
+
+ for (auto it = mipmapped_data_.begin(); it != mipmapped_data_.end(); it++) {
+ Rational rate = it->first;
+ double rate_dbl = rate.to_double();
+ Sample &data = it->second;
+
+ size_t chop_length = time_to_samples(length, rate_dbl);
+
+ data.resize(chop_length);
+ }
+
+ length_ = length;
+}
+
+void AudioVisualWaveform::trim_range(const Rational &in, const Rational &length)
+{
+ trim_in(in);
+ resize(length);
+}
+
+AudioVisualWaveform::Sample
+AudioVisualWaveform::get_summary_from_time(const Rational &start,
+ const Rational &length) const
+{
+ // Find mipmap that requires
+ auto using_mipmap = get_mipmap_for_scale(length.flipped().to_double());
+
+ double rate_dbl = using_mipmap->first.to_double();
+
+ size_t start_sample = time_to_samples(start - virtual_start_, rate_dbl);
+ size_t sample_length = time_to_samples(length, rate_dbl);
+
+ const Sample &mipmap_data = using_mipmap->second;
+
+ // Determine if the array actually has this sample. Compare in signed
+ // arithmetic so a start past the end of the data doesn't underflow.
+ int64_t available = int64_t(mipmap_data.size()) - int64_t(start_sample);
+ if (available > 0) {
+ sample_length = std::min(sample_length, size_t(available));
+
+ if (sample_length > 0) {
+ return re_sum_samples(&mipmap_data.data()[start_sample],
+ sample_length, channels_);
+ }
+ }
+
+ // Return null samples
+ return AudioVisualWaveform::Sample(size_t(channel_count()), { 0, 0 });
+}
+
+void expand_min_max_channel(const float *a, size_t length, float &min_val,
+ float &max_val)
+{
+#if defined(OLIVE_PROCESSOR_X86) || defined(OLIVE_PROCESSOR_ARM)
+ // SSE optimized
+
+ // load the first 4 elements of 'a' into min and max (they are 4 * 32 = 128 bits)
+ __m128 max = _mm_loadu_ps(a);
+ __m128 min = _mm_loadu_ps(a);
+
+ // loop over 'a' and compare current elements with min and max 4 by 4.
+ // we need to make sure we don't read out of boundaries should 'a' length be not mod. 4
+ for (size_t i = 4; i < length - 4; i += 4) {
+ __m128 cur = _mm_loadu_ps(a + i);
+ max = _mm_max_ps(max, cur);
+ min = _mm_min_ps(min, cur);
+ }
+ // so we read the last 4 (or less) elements in a safe manner.
+ __m128 cur = _mm_loadu_ps(a + length - 4);
+ max = _mm_max_ps(max, cur);
+ min = _mm_min_ps(min, cur);
+ // this potentially overlaps up to the last 3 elements but it's not an issue.
+
+ // min and max will contain 4 min and max. To get the absolute min and max
+ // we need to compare the 4 values over themselves by shuffling each time.
+ for (size_t i = 0; i < 3; i++) {
+ max = _mm_max_ps(max, _mm_shuffle_ps(max, max, 0x93));
+ min = _mm_min_ps(min, _mm_shuffle_ps(min, min, 0x93));
+ }
+ // now min and max contain 4 identical items each representing min and max value respectively.
+
+ // and we store the first one into a float variable.
+ _mm_store_ss(&max_val, max);
+ _mm_store_ss(&min_val, min);
+ // I bet you don't find annotated low level code very often.
+#else
+ // Standard unoptimized function
+ for (size_t i = 0; i < length; i++) {
+ min_val = std::min(min_val, a[i]);
+ max_val = std::max(max_val, a[i]);
+ }
+#endif
+}
+
+AudioVisualWaveform::Sample
+AudioVisualWaveform::sum_samples(const core::SampleBuffer &samples,
+ size_t start_index, size_t length)
+{
+ int channels = samples.audio_params().channel_count();
+ const size_t channel_count = size_t(channels);
+ AudioVisualWaveform::Sample summed_samples(channel_count);
+
+ for (int channel = 0; channel < channels; channel++) {
+ expand_min_max_channel(samples.data(channel) + start_index, length,
+ summed_samples[size_t(channel)].min,
+ summed_samples[size_t(channel)].max);
+ }
+
+ // for reference: this approximation is n x faster (and less accurate) for a n-tracks clip
+ // for (size_t i=start_index; idata(i%channels)[i]);
+ // }
+
+ return summed_samples;
+}
+
+AudioVisualWaveform::Sample
+AudioVisualWaveform::re_sum_samples(const SamplePerChannel *samples,
+ size_t nb_samples, int nb_channels)
+{
+ const size_t channel_count = size_t(nb_channels);
+ AudioVisualWaveform::Sample summed_samples(channel_count);
+
+ // Initialize from the first point instead of {0,0}: the engine version
+ // started from zero-initialized pairs, which clamped all-positive
+ // (resp. all-negative) ranges to a zero min (max). Fixed in oakaudio.
+ if (nb_samples >= channel_count) {
+ for (size_t j = 0; j < channel_count; j++) {
+ summed_samples[j] = samples[j];
+ }
+ }
+
+ for (size_t i = 0; i < nb_samples; i += size_t(nb_channels)) {
+ for (int j = 0; j < nb_channels; j++) {
+ const AudioVisualWaveform::SamplePerChannel &sample =
+ samples[i + size_t(j)];
+
+ if (sample.min < summed_samples[size_t(j)].min) {
+ summed_samples[size_t(j)].min = sample.min;
+ }
+
+ if (sample.max > summed_samples[size_t(j)].max) {
+ summed_samples[size_t(j)].max = sample.max;
+ }
+ }
+ }
+
+ return summed_samples;
+}
+
+size_t AudioVisualWaveform::time_to_samples(const Rational &time,
+ double sample_rate) const
+{
+ return time_to_samples(time.to_double(), sample_rate);
+}
+
+size_t AudioVisualWaveform::time_to_samples(const double &time,
+ double sample_rate) const
+{
+ return size_t(std::floor(time * sample_rate)) * size_t(channels_);
+}
+
+std::map::const_iterator
+AudioVisualWaveform::get_mipmap_for_scale(double scale) const
+{
+ // Find largest mipmap for this scale (or the largest if we don't find one sufficient)
+ for (auto it = mipmapped_data_.cbegin(); it != mipmapped_data_.cend();
+ it++) {
+ if (it->first.to_double() >= scale) {
+ return it;
+ }
+ }
+
+ // We don't have a mipmap large enough for this scale, so just return the largest we have
+ return std::prev(mipmapped_data_.cend());
+}
+
+}
diff --git a/src/audio/src/audiovisualwaveform.h b/src/audio/src/audiovisualwaveform.h
new file mode 100644
index 000000000..b721a9b12
--- /dev/null
+++ b/src/audio/src/audiovisualwaveform.h
@@ -0,0 +1,160 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2025 mikesolar
+
+ 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_SUMSAMPLES_H
+#define OAK_SUMSAMPLES_H
+
+#include