From 354df2e194a2c624d8a73248a697735b283965a8 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 6 Aug 2026 20:53:07 +0800 Subject: [PATCH] refactor(config,audio): merge config into oakcommon, split oakaudio - config moves into oakcommon as ConfigStore + oakcommon_config_* C API (INI storage, typed entries, error-handler injection); node and render call sites keep OAK_CONFIG() macro shape via a local shim that forwards to the C API; transition config stubs removed - oakaudio: de-Qt all six classes, C ABI in include/audio with refcounted handles (processor/manager/waveform/levelmeter/sync, 48 functions); PreviewAudioDevice moved in from render; recording goes through oakcodec encoder; waveform extract uses probe + ffmpeg_bridge decode (decode_audio needs M8 task system) - fix re_sum_samples min/max init bug (values clamped to 0 for same-sign ranges) - every C API function has positive + error-path tests; suites: oakcommon 193, oakaudio 36, oaknode 96, oakrender 42, oakcodec 18 --- include/audio/error.h | 59 ++ include/audio/levelmeter.h | 73 +++ include/audio/manager.h | 176 ++++++ include/audio/processor.h | 131 +++++ include/audio/sync.h | 132 +++++ include/audio/waveform.h | 179 ++++++ include/common/config.h | 189 ++++++ src/CMakeLists.txt | 1 + src/audio/CMakeLists.txt | 6 + src/audio/c_api/CMakeLists.txt | 24 + src/audio/c_api/alive.cpp | 49 ++ src/audio/c_api/levelmeter.cpp | 84 +++ src/audio/c_api/manager.cpp | 280 +++++++++ src/audio/c_api/processor.cpp | 148 +++++ src/audio/c_api/refcounted.h | 124 ++++ src/audio/c_api/sync.cpp | 206 +++++++ src/audio/c_api/waveform.cpp | 554 ++++++++++++++++++ src/audio/src/CMakeLists.txt | 71 +++ src/audio/src/audiolevelmeter.cpp | 117 ++++ src/audio/src/audiolevelmeter.h | 57 ++ src/audio/src/audiomanager.cpp | 523 +++++++++++++++++ src/audio/src/audiomanager.h | 142 +++++ src/audio/src/audioprocessor.cpp | 218 +++++++ src/audio/src/audioprocessor.h | 80 +++ src/audio/src/audiosynchronizer.cpp | 65 ++ src/audio/src/audiosynchronizer.h | 55 ++ src/audio/src/audiovisualwaveform.cpp | 478 +++++++++++++++ src/audio/src/audiovisualwaveform.h | 160 +++++ src/audio/src/audiowaveformsync.cpp | 257 ++++++++ src/audio/src/audiowaveformsync.h | 105 ++++ src/audio/src/configbridge.cpp | 54 ++ src/audio/src/configbridge.h | 47 ++ src/audio/src/previewaudiodevice.cpp | 96 +++ src/audio/src/previewaudiodevice.h | 139 +++++ src/audio/standalone/CMakeLists.txt | 158 +++++ src/audio/tests/CMakeLists.txt | 83 +++ src/audio/tests/levelmeter_test.cpp | 117 ++++ src/audio/tests/manager_test.cpp | 207 +++++++ src/audio/tests/processor_test.cpp | 254 ++++++++ src/audio/tests/sync_test.cpp | 258 ++++++++ src/audio/tests/waveform_test.cpp | 353 +++++++++++ src/codec/NOTES.md | 8 +- src/codec/src/proxymanager.cpp | 29 +- src/common/c_api/CMakeLists.txt | 1 + src/common/c_api/config.cpp | 314 ++++++++++ src/common/src/CMakeLists.txt | 2 + src/common/src/configstore.cpp | 406 +++++++++++++ src/common/src/configstore.h | 132 +++++ src/common/tests/CMakeLists.txt | 1 + src/common/tests/config_test.cpp | 384 ++++++++++++ src/node/DEQT.md | 5 +- src/node/src/block/clip/clip.cpp | 2 +- .../src/color/colormanager/colormanager.cpp | 2 +- src/node/src/configaccessor.h | 168 ++++++ src/node/src/node.cpp | 2 +- src/node/src/output/viewer/viewer.cpp | 2 +- src/node/src/project/footage/footage.cpp | 4 +- .../project/serializer/serializer210528.cpp | 6 +- .../project/serializer/serializer210907.cpp | 6 +- .../project/serializer/serializer211228.cpp | 6 +- .../project/serializer/serializer220403.cpp | 2 +- .../project/serializer/serializer230220.cpp | 2 +- src/node/transition/config/config.h | 25 - src/render/src/configaccessor.h | 168 ++++++ src/render/src/diskmanager.cpp | 4 +- src/render/src/lutlibrary.cpp | 2 +- src/render/src/opengl/openglrenderer.cpp | 2 +- src/render/src/previewautocacher.h | 2 +- src/render/src/rendermanager.cpp | 2 +- src/render/src/rendermanager.h | 2 +- src/render/transition/config/config.h | 43 -- 71 files changed, 8142 insertions(+), 101 deletions(-) create mode 100644 include/audio/error.h create mode 100644 include/audio/levelmeter.h create mode 100644 include/audio/manager.h create mode 100644 include/audio/processor.h create mode 100644 include/audio/sync.h create mode 100644 include/audio/waveform.h create mode 100644 include/common/config.h create mode 100644 src/audio/CMakeLists.txt create mode 100644 src/audio/c_api/CMakeLists.txt create mode 100644 src/audio/c_api/alive.cpp create mode 100644 src/audio/c_api/levelmeter.cpp create mode 100644 src/audio/c_api/manager.cpp create mode 100644 src/audio/c_api/processor.cpp create mode 100644 src/audio/c_api/refcounted.h create mode 100644 src/audio/c_api/sync.cpp create mode 100644 src/audio/c_api/waveform.cpp create mode 100644 src/audio/src/CMakeLists.txt create mode 100644 src/audio/src/audiolevelmeter.cpp create mode 100644 src/audio/src/audiolevelmeter.h create mode 100644 src/audio/src/audiomanager.cpp create mode 100644 src/audio/src/audiomanager.h create mode 100644 src/audio/src/audioprocessor.cpp create mode 100644 src/audio/src/audioprocessor.h create mode 100644 src/audio/src/audiosynchronizer.cpp create mode 100644 src/audio/src/audiosynchronizer.h create mode 100644 src/audio/src/audiovisualwaveform.cpp create mode 100644 src/audio/src/audiovisualwaveform.h create mode 100644 src/audio/src/audiowaveformsync.cpp create mode 100644 src/audio/src/audiowaveformsync.h create mode 100644 src/audio/src/configbridge.cpp create mode 100644 src/audio/src/configbridge.h create mode 100644 src/audio/src/previewaudiodevice.cpp create mode 100644 src/audio/src/previewaudiodevice.h create mode 100644 src/audio/standalone/CMakeLists.txt create mode 100644 src/audio/tests/CMakeLists.txt create mode 100644 src/audio/tests/levelmeter_test.cpp create mode 100644 src/audio/tests/manager_test.cpp create mode 100644 src/audio/tests/processor_test.cpp create mode 100644 src/audio/tests/sync_test.cpp create mode 100644 src/audio/tests/waveform_test.cpp create mode 100644 src/common/c_api/config.cpp create mode 100644 src/common/src/configstore.cpp create mode 100644 src/common/src/configstore.h create mode 100644 src/common/tests/config_test.cpp create mode 100644 src/node/src/configaccessor.h delete mode 100644 src/node/transition/config/config.h create mode 100644 src/render/src/configaccessor.h delete mode 100644 src/render/transition/config/config.h 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 +#include + +#include "olive/core/render/samplebuffer.h" +#include "olive/core/util/rational.h" + +namespace olive +{ + +/** + * @brief A buffer of data used to store a visual representation of audio + * + * This differs from a SampleBuffer as the data in an AudioVisualWaveform has been reduced + * significantly and optimized for visual display. + * + * De-Qt note: the QPainter-based draw_sample()/draw_waveform() functions + * live in the app layer now; this class only stores and summarizes data. + */ +class AudioVisualWaveform { +public: + AudioVisualWaveform(); + + struct SamplePerChannel { + float min; + float max; + }; + + using Sample = std::vector; + + int channel_count() const + { + return channels_; + } + + void set_channel_count(int channels) + { + channels_ = channels; + } + + const core::Rational &length() const + { + return length_; + } + + /** + * @brief Writes samples into the visual waveform buffer + * + * Starting at `start`, writes samples over anything in the buffer, expanding it if necessary. + */ + void overwrite_samples(const core::SampleBuffer &samples, int sample_rate, + const core::Rational &start = 0); + + /** + * @brief Replaces sums at a certain range in this visual waveform + * + * @param sums + * + * The sums to write over our current ones with. + * + * @param dest + * + * Where in this visual waveform these sums should START being written to. + * + * @param offset + * + * Where in the `sums` parameter this should start reading from. Defaults to 0. + * + * @param length + * + * Maximum length of `sums` to overwrite with. + */ + void overwrite_sums(const AudioVisualWaveform &sums, + const core::Rational &dest, + const core::Rational &offset = 0, + const core::Rational &length = 0); + + void overwrite_silence(const core::Rational &start, + const core::Rational &length); + + void trim_in(core::Rational length); + + AudioVisualWaveform mid(const core::Rational &offset) const; + AudioVisualWaveform mid(const core::Rational &offset, + const core::Rational &length) const; + + void resize(const core::Rational &length); + + void trim_range(const core::Rational &in, const core::Rational &length); + + Sample get_summary_from_time(const core::Rational &start, + const core::Rational &length) const; + + static Sample sum_samples(const core::SampleBuffer &samples, + size_t start_index, size_t length); + + static Sample re_sum_samples(const SamplePerChannel *samples, + size_t nb_samples, int nb_channels); + + // Must be a power of 2 + static const core::Rational k_minimum_sample_rate; + static const core::Rational k_maximum_sample_rate; + +private: + void overwrite_samples_from_buffer(const core::SampleBuffer &samples, + int sample_rate, + const core::Rational &start, + double target_rate, Sample &data, + size_t &start_index, + size_t &samples_length); + + void overwrite_samples_from_mipmap(const Sample &input, + double input_sample_rate, + size_t &input_start, size_t &input_length, + const core::Rational &start, + double output_rate, Sample &output_data); + + size_t time_to_samples(const core::Rational &time, double sample_rate) const; + size_t time_to_samples(const double &time, double sample_rate) const; + + std::map::const_iterator + get_mipmap_for_scale(double scale) const; + + void validate_virtual_start(const core::Rational &new_start); + + core::Rational virtual_start_; + + int channels_; + + std::map mipmapped_data_; + + core::Rational length_; +}; + +} + +#endif // OAK_SUMSAMPLES_H diff --git a/src/audio/src/audiowaveformsync.cpp b/src/audio/src/audiowaveformsync.cpp new file mode 100644 index 000000000..0572b855f --- /dev/null +++ b/src/audio/src/audiowaveformsync.cpp @@ -0,0 +1,257 @@ +/*** + + 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 "audiowaveformsync.h" + +#include +#include + +namespace olive +{ + +std::vector +AudioWaveformSync::extract_rms_envelope(const core::SampleBuffer &samples, + size_t window_samples) +{ + std::vector envelope; + + const int channel_count = samples.channel_count(); + const size_t sample_count = samples.sample_count(); + if (!channel_count || !sample_count || !window_samples) { + return envelope; + } + + const size_t window_count = + (sample_count + window_samples - 1) / window_samples; + envelope.resize(window_count); + + for (size_t window = 0; window < window_count; window++) { + const size_t start = window * window_samples; + const size_t end = std::min(start + window_samples, sample_count); + double square_sum = 0.0; + size_t total = 0; + + for (int channel = 0; channel < channel_count; channel++) { + const float *data = samples.data(channel); + for (size_t sample = start; sample < end; sample++) { + const double value = data[sample]; + square_sum += value * value; + total++; + } + } + + envelope[window] = + total ? std::sqrt(square_sum / static_cast(total)) : 0.0; + } + + return envelope; +} + +AudioWaveformSync::OffsetResult AudioWaveformSync::estimate_offset( + const core::SampleBuffer &reference, const core::SampleBuffer &candidate, + size_t window_samples, int64_t max_offset_samples) +{ + if (!window_samples) { + return OffsetResult(); + } + + const std::vector reference_envelope = + extract_rms_envelope(reference, window_samples); + const std::vector candidate_envelope = + extract_rms_envelope(candidate, window_samples); + const int64_t max_offset_windows = + max_offset_samples / static_cast(window_samples); + + return estimate_envelope_offset(reference_envelope, candidate_envelope, + window_samples, max_offset_windows); +} + +AudioWaveformSync::OffsetResult AudioWaveformSync::estimate_envelope_offset( + const std::vector &reference, const std::vector &candidate, + size_t window_samples, int64_t max_offset_windows) +{ + return estimate_envelope_offset(reference, candidate, std::vector(), + std::vector(), window_samples, + max_offset_windows); +} + +AudioWaveformSync::OffsetResult AudioWaveformSync::estimate_envelope_offset( + const std::vector &reference, const std::vector &candidate, + const std::vector &reference_valid, + const std::vector &candidate_valid, + size_t window_samples, int64_t max_offset_windows) +{ + OffsetResult result; + if (reference.empty() || candidate.empty() || !window_samples) { + return result; + } + + const auto is_valid = [](const std::vector &mask, size_t size, + size_t index) { + return mask.size() != size || mask.at(index); + }; + + double best_score = -2.0; + int64_t best_lag = 0; + + const int reference_size = static_cast(reference.size()); + const int candidate_size = static_cast(candidate.size()); + + for (int64_t lag = -max_offset_windows; lag <= max_offset_windows; lag++) { + const int reference_start = + static_cast(std::max(0, -lag)); + const int candidate_start = static_cast(std::max(0, lag)); + const int overlap = std::min(reference_size - reference_start, + candidate_size - candidate_start); + + if (overlap < 2) { + continue; + } + + // Only windows marked valid on both sides participate in the score + double reference_mean = 0.0; + double candidate_mean = 0.0; + int valid_count = 0; + for (int i = 0; i < overlap; i++) { + const int reference_index = reference_start + i; + const int candidate_index = candidate_start + i; + if (!is_valid(reference_valid, reference.size(), + size_t(reference_index)) || + !is_valid(candidate_valid, candidate.size(), + size_t(candidate_index))) { + continue; + } + reference_mean += reference.at(size_t(reference_index)); + candidate_mean += candidate.at(size_t(candidate_index)); + valid_count++; + } + + if (valid_count < 2) { + continue; + } + + reference_mean /= static_cast(valid_count); + candidate_mean /= static_cast(valid_count); + + double numerator = 0.0; + double reference_energy = 0.0; + double candidate_energy = 0.0; + for (int i = 0; i < overlap; i++) { + const int reference_index = reference_start + i; + const int candidate_index = candidate_start + i; + if (!is_valid(reference_valid, reference.size(), + size_t(reference_index)) || + !is_valid(candidate_valid, candidate.size(), + size_t(candidate_index))) { + continue; + } + const double reference_value = + reference.at(size_t(reference_index)) - reference_mean; + const double candidate_value = + candidate.at(size_t(candidate_index)) - candidate_mean; + numerator += reference_value * candidate_value; + reference_energy += reference_value * reference_value; + candidate_energy += candidate_value * candidate_value; + } + + // qFuzzyIsNull(double): |x| < 1e-12 + if (std::abs(reference_energy) < 1e-12 || + std::abs(candidate_energy) < 1e-12) { + continue; + } + + const double score = + numerator / std::sqrt(reference_energy * candidate_energy); + if (score > best_score) { + best_score = score; + best_lag = lag; + } + } + + if (best_score > -2.0) { + result.valid = true; + result.confidence = std::max(0.0, best_score); + result.offset_samples = best_lag * static_cast(window_samples); + } + + return result; +} + +AudioWaveformSync::StretchOffsetResult AudioWaveformSync::estimate_stretch_and_offset( + const std::vector &reference, const std::vector &candidate, + const std::vector &reference_valid, + const std::vector &candidate_valid, + size_t window_samples, int64_t max_offset_windows, double min_rate, + double max_rate, double rate_step) +{ + StretchOffsetResult result; + if (reference.empty() || candidate.empty() || !window_samples || + min_rate <= 0.0 || max_rate < min_rate || rate_step <= 0.0) { + return result; + } + + double best_confidence = -2.0; + + for (double rate = min_rate; rate <= max_rate + rate_step * 0.5; + rate += rate_step) { + // Resample the candidate envelope so that window i of the resampled + // envelope corresponds to window i*rate of the original + const int resampled_size = + static_cast(candidate.size() / rate); + if (resampled_size < 2) { + continue; + } + + const size_t resampled_len = size_t(resampled_size); + std::vector resampled(resampled_len); + std::vector resampled_valid(resampled_len); + for (int i = 0; i < resampled_size; i++) { + const double position = i * rate; + const int lower = static_cast(position); + const int upper = + std::min(lower + 1, static_cast(candidate.size()) - 1); + const double fraction = position - lower; + + resampled[size_t(i)] = candidate.at(size_t(lower)) * (1.0 - fraction) + + candidate.at(size_t(upper)) * fraction; + + resampled_valid[size_t(i)] = + (candidate_valid.size() != candidate.size() || + (candidate_valid.at(size_t(lower)) && + candidate_valid.at(size_t(upper)))); + } + + const OffsetResult offset = estimate_envelope_offset( + reference, resampled, reference_valid, resampled_valid, + window_samples, max_offset_windows); + + if (offset.valid && offset.confidence > best_confidence) { + best_confidence = offset.confidence; + result.valid = true; + result.rate = rate; + result.confidence = offset.confidence; + result.offset_samples = offset.offset_samples; + } + } + + return result; +} + +} diff --git a/src/audio/src/audiowaveformsync.h b/src/audio/src/audiowaveformsync.h new file mode 100644 index 000000000..2aba77902 --- /dev/null +++ b/src/audio/src/audiowaveformsync.h @@ -0,0 +1,105 @@ +/*** + + 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_AUDIOWAVEFORMSYNC_H +#define OAK_AUDIOWAVEFORMSYNC_H + +#include +#include + +#include "olive/core/render/samplebuffer.h" + +namespace olive +{ + +class AudioWaveformSync { +public: + struct OffsetResult { + int64_t offset_samples = 0; + double confidence = 0.0; + bool valid = false; + }; + + struct StretchOffsetResult { + // Playback rate the candidate must be played at to align with the + // reference (e.g. 2.0 = candidate runs at half speed and needs to be + // sped up 2x) + double rate = 1.0; + int64_t offset_samples = 0; + double confidence = 0.0; + bool valid = false; + }; + + static std::vector + extract_rms_envelope(const core::SampleBuffer &samples, + size_t window_samples); + + static OffsetResult estimate_offset(const core::SampleBuffer &reference, + const core::SampleBuffer &candidate, + size_t window_samples, + int64_t max_offset_samples); + + static OffsetResult + estimate_envelope_offset(const std::vector &reference, + const std::vector &candidate, + size_t window_samples, + int64_t max_offset_windows); + + /** + * @brief Offset estimation that ignores windows flagged as invalid + * + * @p reference_valid and @p candidate_valid mark which envelope windows + * contain real data (e.g. actually cached waveform regions). Windows + * flagged false on either side are excluded from the correlation instead + * of being treated as silence, which improves accuracy when parts of the + * waveform cache have not been generated yet. Empty masks are treated as + * "all windows valid". + */ + static OffsetResult + estimate_envelope_offset(const std::vector &reference, + const std::vector &candidate, + const std::vector &reference_valid, + const std::vector &candidate_valid, + size_t window_samples, + int64_t max_offset_windows); + + /** + * @brief Estimates a playback-rate change plus offset aligning the + * candidate to the reference + * + * The candidate envelope is resampled at each candidate rate in + * [min_rate, max_rate] (step rate_step) and correlated against the + * reference. rate > 1 means the candidate runs slower than the reference + * and must be sped up. The search is O(rates * lags * overlap), so + * callers should bound max_offset_windows to a sensible range. + */ + static StretchOffsetResult + estimate_stretch_and_offset(const std::vector &reference, + const std::vector &candidate, + const std::vector &reference_valid, + const std::vector &candidate_valid, + size_t window_samples, + int64_t max_offset_windows, double min_rate, + double max_rate, double rate_step); +}; + +} + +#endif // OAK_AUDIOWAVEFORMSYNC_H diff --git a/src/audio/src/configbridge.cpp b/src/audio/src/configbridge.cpp new file mode 100644 index 000000000..6b83b17f2 --- /dev/null +++ b/src/audio/src/configbridge.cpp @@ -0,0 +1,54 @@ +/*** + + 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 "configbridge.h" + +#include + +#include "common/config.h" + +namespace olive::audio_config +{ + +int output_buffer_size() +{ + // 0 = let PortAudio choose the buffer size (old default) + return oakcommon_config_get_int(nullptr, "AudioOutputBufferSize", 0); +} + +std::string device_name(bool is_output_device) +{ + const char *key = is_output_device ? "AudioOutput" : "AudioInput"; + + int size = oakcommon_config_get(nullptr, key, nullptr, 0); + if (size <= 1) { + // Absent (OAKCOMMON_E_NOT_FOUND) or empty + return std::string(); + } + + const size_t buf_len = size_t(size); + std::vector buf(buf_len); + if (oakcommon_config_get(nullptr, key, buf.data(), size) < 0) { + return std::string(); + } + return std::string(buf.data()); +} + +} diff --git a/src/audio/src/configbridge.h b/src/audio/src/configbridge.h new file mode 100644 index 000000000..2945a4a78 --- /dev/null +++ b/src/audio/src/configbridge.h @@ -0,0 +1,47 @@ +/*** + + 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_AUDIO_CONFIGBRIDGE_H +#define OAK_AUDIO_CONFIGBRIDGE_H + +#include + +namespace olive::audio_config +{ + +/** + * @brief Thin wrappers over the oakcommon config C ABI + * (include/common/config.h) + * + * The old engine code read these keys through OAK_CONFIG/OAK_CONFIG_STR; + * oakaudio reaches the same store through oakcommon_config_*. Typed + * getters fall back when the key is absent (the compiled-in defaults do + * not carry the audio device keys). + */ + +/** "AudioOutputBufferSize": PortAudio framesPerBuffer (0 = auto). */ +int output_buffer_size(); + +/** "AudioOutput" / "AudioInput": saved device name ("" when unset). */ +std::string device_name(bool is_output_device); + +} + +#endif // OAK_AUDIO_CONFIGBRIDGE_H diff --git a/src/audio/src/previewaudiodevice.cpp b/src/audio/src/previewaudiodevice.cpp new file mode 100644 index 000000000..0c4063558 --- /dev/null +++ b/src/audio/src/previewaudiodevice.cpp @@ -0,0 +1,96 @@ +/*** + + 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 "previewaudiodevice.h" + +#include +#include + +namespace olive +{ + +PreviewAudioDevice::PreviewAudioDevice() + : bytes_per_frame_(0) + , notify_interval_(0) + , bytes_read_(0) +{ +} + +PreviewAudioDevice::~PreviewAudioDevice() = default; + +void PreviewAudioDevice::set_params(const core::AudioParams ¶ms) +{ + set_bytes_per_frame(params.samples_to_bytes(1)); +} + +int64_t PreviewAudioDevice::read(char *data, int64_t max_size) +{ + bool notify = false; + int64_t copy_length; + { + std::lock_guard locker(lock_); + + copy_length = std::min(max_size, int64_t(buffer_.size())); + + if (copy_length) { + int64_t new_bytes_read = bytes_read_ + copy_length; + + if (notify_interval_ > 0 && notify_callback_) { + if ((bytes_read_ / notify_interval_) != + (new_bytes_read / notify_interval_)) { + notify = true; + } + } + + bytes_read_ = new_bytes_read; + + memcpy(data, buffer_.data(), copy_length); + buffer_.erase(buffer_.begin(), buffer_.begin() + copy_length); + } + } + + // Fired outside the lock (see set_notify_callback()) + if (notify) { + notify_callback_(); + } + + return copy_length; +} + +int64_t PreviewAudioDevice::write(const char *data, int64_t length) +{ + std::lock_guard locker(lock_); + + buffer_.insert(buffer_.end(), data, data + length); + + return length; +} + +void PreviewAudioDevice::clear() +{ + std::lock_guard locker(lock_); + + buffer_.clear(); + bytes_read_ = 0; + output_frames_consumed_.store(0); +} + +} diff --git a/src/audio/src/previewaudiodevice.h b/src/audio/src/previewaudiodevice.h new file mode 100644 index 000000000..3d265b8cd --- /dev/null +++ b/src/audio/src/previewaudiodevice.h @@ -0,0 +1,139 @@ +/*** + + 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_PREVIEWAUDIODEVICE_H +#define OAK_PREVIEWAUDIODEVICE_H + +#include "olive/core/render/audioparams.h" + +#include +#include +#include +#include +#include + +namespace olive +{ + +/** + * @brief Pull-style sample buffer fed to the audio output callback + * + * Formerly a QIODevice subclass consumed by QAudioOutput. Now a plain class: + * the audio backend (PortAudio, see engine/audio AudioManager) pulls samples + * through read() from its stream callback and the render side pushes samples + * through write(). The callback-driven pull semantics are unchanged. + */ +class PreviewAudioDevice { +public: + PreviewAudioDevice(); + + virtual ~PreviewAudioDevice(); + + /** + * @brief Read up to `max_size` bytes from the queued buffer + * + * Called from the audio output callback. Returns the number of bytes + * actually copied (0 when the buffer is empty, i.e. underrun). + */ + int64_t read(char *data, int64_t max_size); + + /** + * @brief Append `length` bytes to the queued buffer + */ + int64_t write(const char *data, int64_t length); + + // Derives the frame size from the audio format (bytes per sample per + // channel * channel count). Until params are set, bytes_per_frame() + // reports 0, i.e. "unknown". + void set_params(const core::AudioParams ¶ms); + + int bytes_per_frame() const + { + return bytes_per_frame_; + } + + void set_bytes_per_frame(int b) + { + bytes_per_frame_ = b; + } + + void set_notify_interval(int64_t i) + { + notify_interval_ = i; + } + + /** + * @brief Install the callback fired when a notify interval boundary is crossed + * + * Replaces the former `notify` signal. The callback is invoked from read(), + * i.e. from the audio output callback thread, AFTER the internal lock has + * been released (the Qt version emitted while holding the lock; receivers + * lived on another thread so it was effectively queued). The callback must + * therefore be thread-safe and must not call back into this device. + */ + void set_notify_callback(std::function callback) + { + std::lock_guard locker(lock_); + notify_callback_ = std::move(callback); + } + + void clear(); + + /** + * @brief Frames consumed by the audio output callback + * + * Counted in the callback itself so underrun (zero-filled) frames are + * included, making the value usable as a playback clock. + */ + void add_output_frames(int64_t frame_count) + { + output_frames_consumed_.fetch_add(frame_count); + } + + int64_t output_frames_consumed() const + { + return output_frames_consumed_.load(); + } + + void reset_output_frames() + { + output_frames_consumed_.store(0); + } + +private: + std::mutex lock_; + + std::vector buffer_; + + int bytes_per_frame_; + + int64_t notify_interval_; + + int64_t bytes_read_; + + std::function notify_callback_; + + std::atomic output_frames_consumed_{0}; +}; + +} + +#endif // OAK_PREVIEWAUDIODEVICE_H diff --git a/src/audio/standalone/CMakeLists.txt b/src/audio/standalone/CMakeLists.txt new file mode 100644 index 000000000..d798f7157 --- /dev/null +++ b/src/audio/standalone/CMakeLists.txt @@ -0,0 +1,158 @@ +# 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 . + +# Standalone build driver for the oakaudio module (M6). Mirrors +# src/codec/standalone: oakaudio links oakcodec (encoder for recording, +# decoder for waveform extraction), oakcommon (config + ffmpegutils C +# ABI), olivecore and ffmpeg_bridge, plus PortAudio for the output +# device. oakcodec's own dependency stack (oakrender/oaknode/...) is +# assembled the same way src/codec/standalone does it. +# +# Usage (macOS/Homebrew): +# cmake -S src/audio/standalone -B build-audio +# cmake --build build-audio -j +# ctest --test-dir build-audio + +cmake_minimum_required(VERSION 3.16 FATAL_ERROR) + +project(oakaudio-standalone LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +get_filename_component(OAK_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE) + +list(APPEND CMAKE_MODULE_PATH "${OAK_REPO_ROOT}/cmake") +if(EXISTS "/opt/homebrew") + list(APPEND CMAKE_PREFIX_PATH "/opt/homebrew") +endif() + +find_package(EXPAT REQUIRED) +find_package(OpenColorIO CONFIG REQUIRED) +find_package(OpenImageIO CONFIG REQUIRED) + +set(OCIO_LIBRARIES OpenColorIO::OpenColorIO) +set(OCIO_INCLUDE_DIRS "") +set(OIIO_LIBRARIES OpenImageIO::OpenImageIO) +set(OIIO_INCLUDE_DIRS "") + +# In-repo libraries, built from source (same set src/codec/standalone +# assembles, because oakaudio links oakcodec): +# - olivecore (core/): oakcore_* C ABI and olive::core C++ wrappers +# - ffmpeg_bridge: fb_* C ABI (resampler infra + codec's FFmpeg access) +# - oakundo / oakcommon / oaknode / oakrender: oakcodec's own dependencies +# - oakcodec: encoder (recording) + decoder (waveform extract) C ABI +set(OLIVECORE_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(CMAKE_DISABLE_FIND_PACKAGE_OpenTimelineIO ON) + +add_subdirectory(${OAK_REPO_ROOT}/core ${CMAKE_BINARY_DIR}/core) +target_include_directories(olivecore PUBLIC ${OAK_REPO_ROOT}/third_party/openfx/include) +add_subdirectory(${OAK_REPO_ROOT}/ffmpeg_bridge ${CMAKE_BINARY_DIR}/ffmpeg_bridge) + +set(BUILD_TESTS OFF) +add_subdirectory(${OAK_REPO_ROOT}/src/undo ${CMAKE_BINARY_DIR}/undo) +add_subdirectory(${OAK_REPO_ROOT}/src/common ${CMAKE_BINARY_DIR}/common) +add_subdirectory(${OAK_REPO_ROOT}/src/node ${CMAKE_BINARY_DIR}/node) +set(BUILD_TESTS ON) + +# oaknode needs its transition stubs when built in this tree (see +# src/render/standalone/CMakeLists.txt). +target_include_directories(oaknode BEFORE PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition + ${OAK_REPO_ROOT}/src/render/src +) +target_include_directories(oaknode PUBLIC + ${OAK_REPO_ROOT}/third_party/openfx/HostSupport/include + /opt/homebrew/include + /opt/homebrew/include/Imath +) +target_link_options(oaknode PRIVATE + "-undefined" "dynamic_lookup" +) + +add_subdirectory(${OAK_REPO_ROOT}/src/render/src ${CMAKE_BINARY_DIR}/render) +add_subdirectory(${OAK_REPO_ROOT}/src/render/c_api ${CMAKE_BINARY_DIR}/render_c_api) + +# Transition stub dirs must precede everything else: src/render/transition +# first, then src/node/transition (shared stubs). +target_include_directories(oakrender BEFORE PUBLIC + ${OAK_REPO_ROOT}/src/render/transition + ${OAK_REPO_ROOT}/src/node/transition +) + +target_include_directories(oakrender PUBLIC + ${OAK_REPO_ROOT}/engine/include + ${OAK_REPO_ROOT}/third_party/openfx/HostSupport/include + /opt/homebrew/include + /opt/homebrew/include/Imath + /opt/homebrew/include/OpenEXR +) + +# Vulkan headers (Homebrew keg-only vulkan-headers). +if(NOT EXISTS "/opt/homebrew/include/vulkan/vulkan.h") + execute_process(COMMAND brew --prefix vulkan-headers + OUTPUT_VARIABLE VULKAN_HEADERS_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + if(VULKAN_HEADERS_PREFIX AND EXISTS "${VULKAN_HEADERS_PREFIX}/include/vulkan/vulkan.h") + target_include_directories(oakrender PUBLIC "${VULKAN_HEADERS_PREFIX}/include") + endif() +endif() + +# Symbols of the not-yet-split engine modules dangle by design. The +# backend libraries resolve most symbols from liboakrender at load time +# and dangle the same way. +foreach(t oakrender oakgl oakgl2 oakvulkan) + if(TARGET ${t}) + target_link_options(${t} PRIVATE + "-undefined" "dynamic_lookup" + ) + endif() +endforeach() + +target_link_libraries(oakrender PRIVATE + oaknode + oakcommon + oakundo + olivecore + ffmpeg_bridge + ${OCIO_LIBRARIES} + ${OIIO_LIBRARIES} + "-framework OpenGL" + "-framework CoreVideo" + "-framework Metal" + "-framework QuartzCore" +) + +# oakcodec (oakaudio's only codec access goes through its C ABI). +add_subdirectory(${OAK_REPO_ROOT}/src/codec/src ${CMAKE_BINARY_DIR}/codec) +add_subdirectory(${OAK_REPO_ROOT}/src/codec/c_api ${CMAKE_BINARY_DIR}/codec_c_api) + +target_link_options(oakcodec PRIVATE + "-undefined" "dynamic_lookup" +) + +# oakaudio itself. +add_subdirectory(${OAK_REPO_ROOT}/src/audio/src ${CMAKE_BINARY_DIR}/audio) +add_subdirectory(${OAK_REPO_ROOT}/src/audio/c_api ${CMAKE_BINARY_DIR}/audio_c_api) + +# Tests (oakaudio-gtest). +if(BUILD_TESTS) + enable_testing() + add_subdirectory(${OAK_REPO_ROOT}/src/audio/tests ${CMAKE_BINARY_DIR}/audio_tests) +endif() diff --git a/src/audio/tests/CMakeLists.txt b/src/audio/tests/CMakeLists.txt new file mode 100644 index 000000000..27ac8b9f7 --- /dev/null +++ b/src/audio/tests/CMakeLists.txt @@ -0,0 +1,83 @@ +# 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 . + +find_package(GTest REQUIRED) +include(GoogleTest) + +# 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() + +add_executable(oakaudio-gtest + levelmeter_test.cpp + manager_test.cpp + processor_test.cpp + sync_test.cpp + waveform_test.cpp +) + +target_link_libraries(oakaudio-gtest PRIVATE + oakaudio + oakcodec + oakrender + oaknode + oakcommon + oakundo + olivecore + GTest::gtest + GTest::gtest_main +) + +# liboakrender/liboaknode dangle OFX host symbols (-undefined +# dynamic_lookup); force-load the host support archive into the test +# process so dyld finds them in the flat namespace at startup. Mirrors +# src/codec/tests/CMakeLists.txt. +if(NOT DEFINED OAKRENDER_OFX_HOST_ARCHIVE) + find_library(OAKRENDER_OFX_HOST_ARCHIVE NAMES OfxHost + PATHS ${OAK_REPO_ROOT}/build/third_party/openfx/HostSupport) +endif() +if(NOT OAKRENDER_OFX_HOST_ARCHIVE) + message(FATAL_ERROR + "libOfxHost.a not found; run the full-tree build once or set " + "OAKRENDER_OFX_HOST_ARCHIVE") +endif() +target_link_options(oakaudio-gtest PRIVATE + "-Wl,-force_load,${OAKRENDER_OFX_HOST_ARCHIVE}") + +# liboakrender references the oakengine_ipc_* C ABI (worker IPC) via +# dynamic_lookup; the test binary links the inert shim from +# src/node/standalone instead. +target_sources(oakaudio-gtest PRIVATE + ${OAK_REPO_ROOT}/src/node/standalone/oakengine_ipc_shim.cpp) +target_include_directories(oakaudio-gtest PRIVATE + ${OAK_REPO_ROOT}/engine/include +) + +# include/ must win over the render/node transition dirs that leak in +# through oaknode's PUBLIC includes: they carry codec/*.h stubs that would +# otherwise shadow the real oakcodec public headers. -iquote is searched +# before every -I for quoted includes. +target_compile_options(oakaudio-gtest PRIVATE + "-iquote" "${OAK_REPO_ROOT}/include" +) + +# tests/demo.mp4 lives at the repo's shared tests directory. +target_compile_definitions(oakaudio-gtest PRIVATE + OAKAUDIO_TEST_DATA_DIR="${OAK_REPO_ROOT}/tests") + +gtest_discover_tests(oakaudio-gtest) diff --git a/src/audio/tests/levelmeter_test.cpp b/src/audio/tests/levelmeter_test.cpp new file mode 100644 index 000000000..77c182c7f --- /dev/null +++ b/src/audio/tests/levelmeter_test.cpp @@ -0,0 +1,117 @@ +/*** + + 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 + +#include + +#include "audio/levelmeter.h" + +TEST(OakAudioLevelMeter, AnalyzeConstantSignal) +{ + // Constant 0.5 on both channels: peak = rms = 0.5, dB = 20*log10(0.5) + std::vector ch(1024, 0.5f); + const float *planes[2] = { ch.data(), ch.data() }; + oakaudio_channel_stats channels[2]; + oakaudio_meter_stats summary; + + ASSERT_EQ(oakaudio_levelmeter_analyze(planes, 2, 1024, channels, 2, + &summary), + OAKAUDIO_OK); + + for (int c = 0; c < 2; c++) { + EXPECT_DOUBLE_EQ(channels[c].peak_linear, 0.5); + EXPECT_DOUBLE_EQ(channels[c].rms_linear, 0.5); + EXPECT_NEAR(channels[c].peak_db, 20.0 * std::log10(0.5), 1e-9); + EXPECT_NEAR(channels[c].rms_db, 20.0 * std::log10(0.5), 1e-9); + EXPECT_DOUBLE_EQ(channels[c].vu_db, channels[c].rms_db); + } + + EXPECT_DOUBLE_EQ(summary.max_peak_linear, 0.5); + EXPECT_EQ(summary.silence, 0); + // LUFS = -0.691 + 10*log10(mean square) = -0.691 + 10*log10(0.25) + EXPECT_NEAR(summary.integrated_lufs, -0.691 + 10.0 * std::log10(0.25), + 1e-9); +} + +TEST(OakAudioLevelMeter, AnalyzeSilence) +{ + std::vector ch(512, 0.0f); + const float *planes[1] = { ch.data() }; + oakaudio_meter_stats summary; + + ASSERT_EQ(oakaudio_levelmeter_analyze(planes, 1, 512, nullptr, 0, + &summary), + OAKAUDIO_OK); + EXPECT_EQ(summary.silence, 1); + EXPECT_DOUBLE_EQ(summary.max_peak_linear, 0.0); + EXPECT_DOUBLE_EQ(summary.integrated_lufs, -200.0); +} + +TEST(OakAudioLevelMeter, AnalyzePeakPerChannel) +{ + std::vector quiet(256, 0.1f); + std::vector loud(256, 0.0f); + loud[7] = -0.8f; // single peak + const float *planes[2] = { quiet.data(), loud.data() }; + oakaudio_channel_stats channels[2]; + + ASSERT_EQ(oakaudio_levelmeter_analyze(planes, 2, 256, channels, 2, + nullptr), + OAKAUDIO_OK); + EXPECT_NEAR(channels[0].peak_linear, 0.1, 1e-6); + EXPECT_NEAR(channels[1].peak_linear, 0.8, 1e-6); + // dB floor: the zero samples dominate, but the peak channel has signal + EXPECT_GT(channels[1].peak_db, channels[0].peak_db); +} + +TEST(OakAudioLevelMeter, AnalyzeErrorPaths) +{ + std::vector ch(64, 0.5f); + const float *planes[1] = { ch.data() }; + oakaudio_channel_stats channels[1]; + oakaudio_meter_stats summary; + + // NULL planes + EXPECT_EQ(oakaudio_levelmeter_analyze(nullptr, 1, 64, channels, 1, + &summary), + OAKAUDIO_E_INVALID); + // Zero channels + EXPECT_EQ(oakaudio_levelmeter_analyze(planes, 0, 64, channels, 1, + &summary), + OAKAUDIO_E_INVALID); + // Negative frame count + EXPECT_EQ(oakaudio_levelmeter_analyze(planes, 1, -1, channels, 1, + &summary), + OAKAUDIO_E_INVALID); + // Insufficient channel capacity + EXPECT_EQ(oakaudio_levelmeter_analyze(planes, 1, 64, channels, 0, + &summary), + OAKAUDIO_E_INVALID); + // Both outs NULL + EXPECT_EQ(oakaudio_levelmeter_analyze(planes, 1, 64, nullptr, 0, nullptr), + OAKAUDIO_E_INVALID); + // NULL plane inside array + const float *bad_planes[1] = { nullptr }; + EXPECT_EQ(oakaudio_levelmeter_analyze(bad_planes, 1, 64, channels, 1, + &summary), + OAKAUDIO_E_INVALID); +} diff --git a/src/audio/tests/manager_test.cpp b/src/audio/tests/manager_test.cpp new file mode 100644 index 000000000..e29bbe974 --- /dev/null +++ b/src/audio/tests/manager_test.cpp @@ -0,0 +1,207 @@ +/*** + + 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 + +#include + +#include "audio/manager.h" + +namespace +{ + +constexpr int kSampleFmtF32 = 10; // olive::core::SampleFormat::f32 +constexpr uint64_t kLayoutStereo = 0x3; + +// Creates the singleton for the duration of the test; skips when no +// audio device environment is available. +struct ManagerFixture { + ManagerFixture() + { + created = (oakaudio_manager_create_instance() == OAKAUDIO_OK) && + oakaudio_manager_instance().ctx != nullptr; + } + ~ManagerFixture() + { + if (created) { + oakaudio_manager_destroy_instance(); + } + } + bool created = false; +}; + +} // namespace + +TEST(OakAudioManager, InstanceLifecycle) +{ + // Without an instance all calls report E_STATE and instance() is empty + OakAudioManager none = oakaudio_manager_instance(); + EXPECT_EQ(none.ctx, nullptr); + EXPECT_EQ(none.abi_version, OAKAUDIO_ABI_VERSION); + + double secs; + EXPECT_EQ(oakaudio_manager_seconds(none, &secs), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_get_output_device(none), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_get_input_device(none), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_set_output_device(none, 0), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_set_input_device(none, 0), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_set_output_notify_interval(none, 1024), + OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_clear_buffered_output(none), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_stop_output(none), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_reset_output_clock(none), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_hard_reset(none), OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_manager_stop_recording(none), OAKAUDIO_E_STATE); + + float samples[2] = { 0.0f, 0.0f }; + EXPECT_EQ(oakaudio_manager_push_to_output(none, 48000, kLayoutStereo, + kSampleFmtF32, + reinterpret_cast(samples), + sizeof(samples), nullptr, 0), + OAKAUDIO_E_STATE); + + oakcodec_encoding_params params; + std::memset(¶ms, 0, sizeof(params)); + params.audio_enabled = 1; + EXPECT_EQ(oakaudio_manager_start_recording(none, ¶ms, nullptr, 0), + OAKAUDIO_E_STATE); + + // free is a no-op and safe on NULL/empty + oakaudio_manager_free(nullptr); + oakaudio_manager_free(&none); + EXPECT_EQ(none.ctx, nullptr); +} + +TEST(OakAudioManager, DeviceRoundTrip) +{ + ManagerFixture fx; + if (!fx.created) { + GTEST_SKIP() << "no PortAudio device environment"; + } + + OakAudioManager m = oakaudio_manager_instance(); + ASSERT_NE(m.ctx, nullptr); + // Singleton: addref/release never destroy + m.addref(m.ctx); + m.release(m.ctx); + EXPECT_EQ(oakaudio_manager_instance().ctx, m.ctx); + + // Devices: whatever was detected, get/set round-trips + const int out_device = oakaudio_manager_get_output_device(m); + EXPECT_EQ(oakaudio_manager_set_output_device(m, out_device), OAKAUDIO_OK); + EXPECT_EQ(oakaudio_manager_get_output_device(m), out_device); + + const int in_device = oakaudio_manager_get_input_device(m); + EXPECT_EQ(oakaudio_manager_set_input_device(m, in_device), OAKAUDIO_OK); + EXPECT_EQ(oakaudio_manager_get_input_device(m), in_device); + + // Notify interval set/get-free command + EXPECT_EQ(oakaudio_manager_set_output_notify_interval(m, 4096), + OAKAUDIO_OK); + EXPECT_EQ(oakaudio_manager_set_output_notify_interval(m, -1), + OAKAUDIO_E_INVALID); + + // No stream running: seconds() is negative, clock commands are valid + double secs = 1.0; + EXPECT_EQ(oakaudio_manager_seconds(m, &secs), OAKAUDIO_OK); + EXPECT_LT(secs, 0.0); + EXPECT_EQ(oakaudio_manager_seconds(m, nullptr), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_manager_reset_output_clock(m), OAKAUDIO_OK); + EXPECT_EQ(oakaudio_manager_clear_buffered_output(m), OAKAUDIO_OK); + EXPECT_EQ(oakaudio_manager_stop_output(m), OAKAUDIO_OK); + EXPECT_EQ(oakaudio_manager_hard_reset(m), OAKAUDIO_OK); + EXPECT_EQ(oakaudio_manager_stop_recording(m), OAKAUDIO_OK); +} + +TEST(OakAudioManager, PushToOutput) +{ + ManagerFixture fx; + if (!fx.created) { + GTEST_SKIP() << "no PortAudio device environment"; + } + + OakAudioManager m = oakaudio_manager_instance(); + if (oakaudio_manager_get_output_device(m) < 0) { + GTEST_SKIP() << "no output device"; + } + + // One second of silence, packed f32 stereo + std::vector silence(48000 * 2, 0.0f); + char error[256]; + EXPECT_EQ(oakaudio_manager_push_to_output( + m, 48000, kLayoutStereo, kSampleFmtF32, + reinterpret_cast(silence.data()), + int64_t(silence.size() * sizeof(float)), error, + int(sizeof(error))), + OAKAUDIO_OK); + + // Error paths + EXPECT_EQ(oakaudio_manager_push_to_output( + m, 0, kLayoutStereo, kSampleFmtF32, + reinterpret_cast(silence.data()), 16, nullptr, 0), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_manager_push_to_output(m, 48000, kLayoutStereo, + kSampleFmtF32, nullptr, 16, + nullptr, 0), + OAKAUDIO_E_INVALID); + + oakaudio_manager_stop_output(m); +} + +TEST(OakAudioManager, StartRecordingErrorPaths) +{ + ManagerFixture fx; + if (!fx.created) { + GTEST_SKIP() << "no PortAudio device environment"; + } + + OakAudioManager m = oakaudio_manager_instance(); + + // NULL params / audio disabled + EXPECT_EQ(oakaudio_manager_start_recording(m, nullptr, nullptr, 0), + OAKAUDIO_E_INVALID); + oakcodec_encoding_params params; + std::memset(¶ms, 0, sizeof(params)); + EXPECT_EQ(oakaudio_manager_start_recording(m, ¶ms, nullptr, 0), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioManager, FindDeviceByName) +{ + ManagerFixture fx; + if (!fx.created) { + GTEST_SKIP() << "no PortAudio device environment"; + } + + // A name that matches nothing falls back to the default device (or + // paNoDevice on device-less systems); either way no crash and a valid + // index or -1. + const int out = oakaudio_manager_find_device_by_name_s( + "definitely-not-a-real-device-name-oakaudio-test", 1); + EXPECT_GE(out, -1); + + const int cfg = oakaudio_manager_find_config_device_by_name_s(1); + EXPECT_GE(cfg, -1); + + // Error path: NULL name + EXPECT_EQ(oakaudio_manager_find_device_by_name_s(nullptr, 1), + OAKAUDIO_E_INVALID); +} diff --git a/src/audio/tests/processor_test.cpp b/src/audio/tests/processor_test.cpp new file mode 100644 index 000000000..f695ca483 --- /dev/null +++ b/src/audio/tests/processor_test.cpp @@ -0,0 +1,254 @@ +/*** + + 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 + +#include + +#include "audio/manager.h" +#include "audio/processor.h" + +namespace +{ + +constexpr int kSampleFmtF32P = 4; // olive::core::SampleFormat::f32_p +constexpr uint64_t kLayoutStereo = 0x3; + +struct ProcessorHandle { + OakAudioProcessor h = oakaudio_processor_init(); + ~ProcessorHandle() { oakaudio_processor_free(&h); } +}; + +// Feed a full buffer through the processor and return the total number of +// output frames produced (input drained + flushed). +int convert_all(OakAudioProcessor p, const std::vector> &in, + int chunk) +{ + const int channels = int(in.size()); + const size_t nch = size_t(channels); + std::vector in_planes(nch); + std::vector> out_store(nch); + std::vector out_planes(nch); + for (int ch = 0; ch < channels; ch++) { + in_planes[size_t(ch)] = in[size_t(ch)].data(); + out_store[size_t(ch)].resize(size_t(chunk) * 4 + 4096); + out_planes[size_t(ch)] = out_store[size_t(ch)].data(); + } + + int total = 0; + const int frames = int(in[0].size()); + for (int pos = 0; pos < frames; pos += chunk) { + const int n = std::min(chunk, frames - pos); + std::vector window(nch); + for (int ch = 0; ch < channels; ch++) { + window[size_t(ch)] = in[size_t(ch)].data() + pos; + } + const int produced = oakaudio_processor_convert( + p, window.data(), n, out_planes.data(), int(out_store[0].size())); + if (produced < 0) { + return produced; + } + total += produced; + } + + EXPECT_EQ(oakaudio_processor_flush(p), OAKAUDIO_OK); + // Drain the resampler's internal delay + for (int guard = 0; guard < 64; guard++) { + const int produced = oakaudio_processor_convert( + p, nullptr, 0, out_planes.data(), int(out_store[0].size())); + if (produced <= 0) { + break; + } + total += produced; + } + return total; +} + +std::vector> make_sine(int channels, int frames, int rate) +{ + const size_t nch = size_t(channels); + std::vector> data(nch); + for (int ch = 0; ch < channels; ch++) { + data[size_t(ch)].resize(size_t(frames)); + for (int i = 0; i < frames; i++) { + data[size_t(ch)][size_t(i)] = + 0.5f * std::sin(2.0 * M_PI * 440.0 * i / rate); + } + } + return data; +} + +} // namespace + +TEST(OakAudioProcessor, InitFree) +{ + const int before = oakaudio_debug_alive_count(); + { + ProcessorHandle p; + ASSERT_NE(p.h.ctx, nullptr); + EXPECT_EQ(oakaudio_debug_alive_count(), before + 1); + } + EXPECT_EQ(oakaudio_debug_alive_count(), before); + + // free is a no-op on NULL / empty handles + oakaudio_processor_free(nullptr); + OakAudioProcessor empty = {}; + oakaudio_processor_free(&empty); +} + +TEST(OakAudioProcessor, OpenCloseIsOpen) +{ + ProcessorHandle p; + EXPECT_EQ(oakaudio_processor_is_open(p.h), 0); + + EXPECT_EQ(oakaudio_processor_open(p.h, 44100, kLayoutStereo, kSampleFmtF32P, + 48000, kLayoutStereo, kSampleFmtF32P, 1.0), + OAKAUDIO_OK); + EXPECT_EQ(oakaudio_processor_is_open(p.h), 1); + + // Error path: opening an open processor + EXPECT_EQ(oakaudio_processor_open(p.h, 44100, kLayoutStereo, kSampleFmtF32P, + 48000, kLayoutStereo, kSampleFmtF32P, 1.0), + OAKAUDIO_E_STATE); + + EXPECT_EQ(oakaudio_processor_close(p.h), OAKAUDIO_OK); + EXPECT_EQ(oakaudio_processor_is_open(p.h), 0); +} + +TEST(OakAudioProcessor, OpenInvalidArgs) +{ + ProcessorHandle p; + // Unsupported output format (only f32p is delivered) + EXPECT_EQ(oakaudio_processor_open(p.h, 44100, kLayoutStereo, kSampleFmtF32P, + 48000, kLayoutStereo, 1, 1.0), + OAKAUDIO_E_INVALID); + // Bad sample rate + EXPECT_EQ(oakaudio_processor_open(p.h, 0, kLayoutStereo, kSampleFmtF32P, + 48000, kLayoutStereo, kSampleFmtF32P, 1.0), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_processor_is_open(p.h), 0); + + // Empty handle + OakAudioProcessor empty = {}; + EXPECT_EQ(oakaudio_processor_open(empty, 44100, kLayoutStereo, + kSampleFmtF32P, 48000, kLayoutStereo, + kSampleFmtF32P, 1.0), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_processor_is_open(empty), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_processor_close(empty), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_processor_flush(empty), OAKAUDIO_E_INVALID); +} + +TEST(OakAudioProcessor, ConvertResample441To48) +{ + const int before = oakaudio_debug_alive_count(); + ProcessorHandle p; + ASSERT_EQ(oakaudio_processor_open(p.h, 44100, kLayoutStereo, + kSampleFmtF32P, 48000, kLayoutStereo, + kSampleFmtF32P, 1.0), + OAKAUDIO_OK); + + const int in_frames = 44100; // one second + const auto sine = make_sine(2, in_frames, 44100); + const int produced = convert_all(p.h, sine, 4096); + ASSERT_GE(produced, 0); + + // One second at 44.1k must become (within resampler tolerance) one + // second at 48k. + EXPECT_NEAR(produced, 48000, 200); + + // Re-open check for leaks + oakaudio_processor_close(p.h); + oakaudio_processor_free(&p.h); + EXPECT_EQ(oakaudio_debug_alive_count(), before); +} + +TEST(OakAudioProcessor, ConvertSilenceStaysSilent) +{ + ProcessorHandle p; + ASSERT_EQ(oakaudio_processor_open(p.h, 48000, kLayoutStereo, + kSampleFmtF32P, 48000, kLayoutStereo, + kSampleFmtF32P, 1.0), + OAKAUDIO_OK); + + std::vector> silence(2, std::vector(4096, 0.0f)); + std::vector> out(2, std::vector(8192, -1.0f)); + std::vector in_planes = { silence[0].data(), + silence[1].data() }; + std::vector out_planes = { out[0].data(), out[1].data() }; + + const int produced = oakaudio_processor_convert( + p.h, in_planes.data(), 4096, out_planes.data(), 8192); + ASSERT_GT(produced, 0); + EXPECT_EQ(produced, 4096); // same rate in/out: 1:1 frames + for (int i = 0; i < produced; i++) { + EXPECT_FLOAT_EQ(out[0][size_t(i)], 0.0f); + EXPECT_FLOAT_EQ(out[1][size_t(i)], 0.0f); + } +} + +TEST(OakAudioProcessor, ConvertTempo) +{ + ProcessorHandle p; + ASSERT_EQ(oakaudio_processor_open(p.h, 48000, kLayoutStereo, + kSampleFmtF32P, 48000, kLayoutStereo, + kSampleFmtF32P, 1.5), + OAKAUDIO_OK); + + const auto sine = make_sine(2, 48000, 48000); + const int produced = convert_all(p.h, sine, 4096); + ASSERT_GE(produced, 0); + + // 1.5x tempo: one second of input becomes roughly 2/3 second of + // output (atempo works on correlated windows, so allow slack) + EXPECT_NEAR(produced, int(48000 / 1.5), 3000); +} + +TEST(OakAudioProcessor, ConvertErrorPaths) +{ + ProcessorHandle p; + + // Convert on a closed processor + float dummy = 0.0f; + float *out_planes[1] = { &dummy }; + const float *in_planes[1] = { &dummy }; + EXPECT_EQ(oakaudio_processor_convert(p.h, in_planes, 1, out_planes, 1), + OAKAUDIO_E_STATE); + EXPECT_EQ(oakaudio_processor_flush(p.h), OAKAUDIO_E_STATE); + + // Empty handle + OakAudioProcessor empty = {}; + EXPECT_EQ(oakaudio_processor_convert(empty, in_planes, 1, out_planes, 1), + OAKAUDIO_E_INVALID); + + // NULL input planes with frames + ASSERT_EQ(oakaudio_processor_open(p.h, 48000, kLayoutStereo, + kSampleFmtF32P, 48000, kLayoutStereo, + kSampleFmtF32P, 1.0), + OAKAUDIO_OK); + EXPECT_EQ(oakaudio_processor_convert(p.h, nullptr, 10, out_planes, 1), + OAKAUDIO_E_INVALID); + // Negative counts + EXPECT_EQ(oakaudio_processor_convert(p.h, in_planes, -1, out_planes, 1), + OAKAUDIO_E_INVALID); + + EXPECT_EQ(oakaudio_processor_flush(p.h), OAKAUDIO_OK); +} diff --git a/src/audio/tests/sync_test.cpp b/src/audio/tests/sync_test.cpp new file mode 100644 index 000000000..77f29f820 --- /dev/null +++ b/src/audio/tests/sync_test.cpp @@ -0,0 +1,258 @@ +/*** + + 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 + +#include + +#include "audio/sync.h" + +namespace +{ + +// 8-window synthetic envelope with a distinctive shape +std::vector make_envelope() +{ + return { 0.1, 0.5, 0.9, 0.3, 0.2, 0.8, 0.4, 0.1 }; +} + +} // namespace + +TEST(OakAudioSync, ExtractRmsEnvelope) +{ + // Mono, window = 4 frames: window 0 constant 0.5 -> RMS 0.5 + std::vector ch(8, 0.0f); + for (int i = 0; i < 4; i++) + ch[size_t(i)] = 0.5f; + const float *planes[1] = { ch.data() }; + + // Query mode + const int windows = + oakaudio_sync_extract_rms_envelope(planes, 1, 8, 4, nullptr, 0); + ASSERT_EQ(windows, 2); + + double envelope[2]; + ASSERT_EQ(oakaudio_sync_extract_rms_envelope(planes, 1, 8, 4, envelope, 2), + 2); + EXPECT_DOUBLE_EQ(envelope[0], 0.5); + EXPECT_DOUBLE_EQ(envelope[1], 0.0); +} + +TEST(OakAudioSync, ExtractRmsEnvelopeErrorPaths) +{ + float v = 0.0f; + const float *planes[1] = { &v }; + double out[1]; + + EXPECT_EQ(oakaudio_sync_extract_rms_envelope(nullptr, 1, 8, 4, out, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_sync_extract_rms_envelope(planes, 0, 8, 4, out, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_sync_extract_rms_envelope(planes, 1, 8, 0, out, 1), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioSync, EstimateEnvelopeOffset) +{ + const std::vector ref = make_envelope(); + // Candidate = reference shifted right by 2 windows + std::vector cand = { 0.0, 0.0 }; + cand.insert(cand.end(), ref.begin(), ref.end()); + + oakaudio_offset_result out; + ASSERT_EQ(oakaudio_sync_estimate_envelope_offset( + ref.data(), int(ref.size()), cand.data(), int(cand.size()), + nullptr, nullptr, 100, 8, &out), + OAKAUDIO_OK); + EXPECT_EQ(out.valid, 1); + EXPECT_EQ(out.offset_samples, 2 * 100); + EXPECT_GT(out.confidence, 0.9); +} + +TEST(OakAudioSync, EstimateEnvelopeOffsetWithMasks) +{ + const std::vector ref = make_envelope(); + std::vector cand = { 0.0, 0.0 }; + cand.insert(cand.end(), ref.begin(), ref.end()); + + std::vector all_valid_ref(ref.size(), 1); + std::vector all_valid_cand(cand.size(), 1); + + oakaudio_offset_result out; + ASSERT_EQ(oakaudio_sync_estimate_envelope_offset( + ref.data(), int(ref.size()), cand.data(), int(cand.size()), + all_valid_ref.data(), all_valid_cand.data(), 100, 8, &out), + OAKAUDIO_OK); + EXPECT_EQ(out.valid, 1); + EXPECT_EQ(out.offset_samples, 200); +} + +TEST(OakAudioSync, EstimateEnvelopeOffsetErrorPaths) +{ + double env[4] = { 0.1, 0.2, 0.3, 0.4 }; + oakaudio_offset_result out; + + EXPECT_EQ(oakaudio_sync_estimate_envelope_offset(nullptr, 4, env, 4, + nullptr, nullptr, 100, 4, + &out), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_sync_estimate_envelope_offset(env, 0, env, 4, nullptr, + nullptr, 100, 4, &out), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_sync_estimate_envelope_offset(env, 4, env, 4, nullptr, + nullptr, 0, 4, &out), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_sync_estimate_envelope_offset(env, 4, env, 4, nullptr, + nullptr, 100, 4, nullptr), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioSync, EstimateStretchAndOffset) +{ + const std::vector ref = make_envelope(); + // Candidate runs at half speed: each ref window duplicated + std::vector cand; + for (double v : ref) { + cand.push_back(v); + cand.push_back(v); + } + + oakaudio_stretch_offset_result out; + ASSERT_EQ(oakaudio_sync_estimate_stretch_and_offset( + ref.data(), int(ref.size()), cand.data(), int(cand.size()), + nullptr, nullptr, 100, 4, 1.0, 2.0, 0.25, &out), + OAKAUDIO_OK); + EXPECT_EQ(out.valid, 1); + EXPECT_NEAR(out.rate, 2.0, 0.13); + EXPECT_GT(out.confidence, 0.9); +} + +TEST(OakAudioSync, EstimateStretchAndOffsetErrorPaths) +{ + double env[4] = { 0.1, 0.2, 0.3, 0.4 }; + oakaudio_stretch_offset_result out; + + EXPECT_EQ(oakaudio_sync_estimate_stretch_and_offset( + nullptr, 4, env, 4, nullptr, nullptr, 100, 4, 1.0, 2.0, 0.5, + &out), + OAKAUDIO_E_INVALID); + // min_rate <= 0 + EXPECT_EQ(oakaudio_sync_estimate_stretch_and_offset( + env, 4, env, 4, nullptr, nullptr, 100, 4, 0.0, 2.0, 0.5, &out), + OAKAUDIO_E_INVALID); + // max < min + EXPECT_EQ(oakaudio_sync_estimate_stretch_and_offset( + env, 4, env, 4, nullptr, nullptr, 100, 4, 2.0, 1.0, 0.5, &out), + OAKAUDIO_E_INVALID); + // NULL out + EXPECT_EQ(oakaudio_sync_estimate_stretch_and_offset( + env, 4, env, 4, nullptr, nullptr, 100, 4, 1.0, 2.0, 0.5, + nullptr), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioSync, PlaceBySourceTime) +{ + oakaudio_source_clip ref = {}; + ref.source_start_time_num = 10; // source clock at 10s + ref.source_start_time_den = 1; + ref.media_in_num = 2; // clip head is 2s into the media + ref.media_in_den = 1; + ref.has_source_start_time = 1; + + oakaudio_source_clip cand = {}; + cand.source_start_time_num = 14; // 4s later on the same source clock + cand.source_start_time_den = 1; + cand.media_in_num = 0; + cand.media_in_den = 1; + cand.has_source_start_time = 1; + + int64_t num, den; + int valid; + ASSERT_EQ(oakaudio_sync_place_by_source_time(&ref, &cand, 5, 1, &num, &den, + &valid), + OAKAUDIO_OK); + EXPECT_EQ(valid, 1); + // candidate head source = 14+0, reference head source = 10+2 = 12; + // timeline_in = 5 + 14 - 12 = 7 + EXPECT_EQ(num, 7); + EXPECT_EQ(den, 1); + + // Missing source start time -> invalid placement, still OAKAUDIO_OK + cand.has_source_start_time = 0; + ASSERT_EQ(oakaudio_sync_place_by_source_time(&ref, &cand, 5, 1, &num, &den, + &valid), + OAKAUDIO_OK); + EXPECT_EQ(valid, 0); +} + +TEST(OakAudioSync, PlaceBySourceTimeErrorPaths) +{ + oakaudio_source_clip clip = {}; + clip.source_start_time_den = 1; + clip.media_in_den = 1; + clip.has_source_start_time = 1; + + int64_t num, den; + int valid; + EXPECT_EQ(oakaudio_sync_place_by_source_time(nullptr, &clip, 5, 1, &num, + &den, &valid), + OAKAUDIO_E_INVALID); + // Zero denominators + oakaudio_source_clip bad = clip; + bad.media_in_den = 0; + EXPECT_EQ(oakaudio_sync_place_by_source_time(&clip, &bad, 5, 1, &num, &den, + &valid), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_sync_place_by_source_time(&clip, &clip, 5, 0, &num, &den, + &valid), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_sync_place_by_source_time(&clip, &clip, 5, 1, nullptr, + &den, &valid), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioSync, PlaceByWaveformOffset) +{ + int64_t num, den; + int valid; + + // Reference at 5s, candidate is 24000 samples late at 48k -> 5.5s + ASSERT_EQ(oakaudio_sync_place_by_waveform_offset(5, 1, 24000, 48000, &num, + &den, &valid), + OAKAUDIO_OK); + EXPECT_EQ(valid, 1); + EXPECT_NEAR(double(num) / double(den), 5.5, 1e-6); + + // Bad sample rate -> invalid placement + ASSERT_EQ(oakaudio_sync_place_by_waveform_offset(5, 1, 24000, 0, &num, &den, + &valid), + OAKAUDIO_OK); + EXPECT_EQ(valid, 0); + + // Error path: NULL outs / zero denominator + EXPECT_EQ(oakaudio_sync_place_by_waveform_offset(5, 0, 24000, 48000, &num, + &den, &valid), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_sync_place_by_waveform_offset(5, 1, 24000, 48000, + nullptr, &den, &valid), + OAKAUDIO_E_INVALID); +} diff --git a/src/audio/tests/waveform_test.cpp b/src/audio/tests/waveform_test.cpp new file mode 100644 index 000000000..33e8599ab --- /dev/null +++ b/src/audio/tests/waveform_test.cpp @@ -0,0 +1,353 @@ +/*** + + 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 +#include + +#include + +#include "audio/levelmeter.h" +#include "audio/manager.h" +#include "audio/waveform.h" +#include "codec/decoder.h" + +namespace +{ + +struct WaveformHandle { + OakAudioWaveform h = oakaudio_waveform_init(); + ~WaveformHandle() { oakaudio_waveform_free(&h); } +}; + +std::string demo_file() +{ + return std::string(OAKAUDIO_TEST_DATA_DIR) + "/demo.mp4"; +} + +} // namespace + +TEST(OakAudioWaveform, InitFree) +{ + const int before = oakaudio_debug_alive_count(); + { + WaveformHandle w; + ASSERT_NE(w.h.ctx, nullptr); + EXPECT_EQ(oakaudio_debug_alive_count(), before + 1); + EXPECT_EQ(oakaudio_waveform_get_channel_count(w.h), 0); + } + EXPECT_EQ(oakaudio_debug_alive_count(), before); + + oakaudio_waveform_free(nullptr); + OakAudioWaveform empty = {}; + oakaudio_waveform_free(&empty); +} + +TEST(OakAudioWaveform, ChannelCountAndLength) +{ + WaveformHandle w; + EXPECT_EQ(oakaudio_waveform_set_channel_count(w.h, 2), OAKAUDIO_OK); + EXPECT_EQ(oakaudio_waveform_get_channel_count(w.h), 2); + + int64_t num = -1, den = -1; + EXPECT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK); + EXPECT_EQ(num, 0); + + // Error paths + OakAudioWaveform empty = {}; + EXPECT_EQ(oakaudio_waveform_get_channel_count(empty), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_set_channel_count(empty, 2), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_set_channel_count(w.h, -1), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_length(empty, &num, &den), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_length(w.h, nullptr, &den), OAKAUDIO_E_INVALID); +} + +TEST(OakAudioWaveform, OverwriteSamplesAndSummary) +{ + WaveformHandle w; + ASSERT_EQ(oakaudio_waveform_set_channel_count(w.h, 1), OAKAUDIO_OK); + + // One second at 48k: first half +0.5, second half -0.5 + std::vector data(48000); + for (int i = 0; i < 48000; i++) { + data[size_t(i)] = (i < 24000) ? 0.5f : -0.5f; + } + const float *planes[1] = { data.data() }; + EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, planes, 48000, 48000, 0, + 1), + OAKAUDIO_OK); + + int64_t num, den; + ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK); + EXPECT_NEAR(double(num) / double(den), 1.0, 1e-9); + + // Summary of the whole second: min -0.5, max +0.5 + oakaudio_min_max pairs[2]; + const int points = + oakaudio_waveform_get_summary(w.h, 0, 1, 1, 1, pairs, 2); + ASSERT_EQ(points, 1); + EXPECT_FLOAT_EQ(pairs[0].min, -0.5f); + EXPECT_FLOAT_EQ(pairs[0].max, 0.5f); + + // Summary of the first half only: all +0.5 + const int first_half = + oakaudio_waveform_get_summary(w.h, 0, 1, 1, 2, pairs, 2); + ASSERT_EQ(first_half, 1); + EXPECT_FLOAT_EQ(pairs[0].min, 0.5f); + EXPECT_FLOAT_EQ(pairs[0].max, 0.5f); + + // Query mode: NULL out returns the point count + EXPECT_EQ(oakaudio_waveform_get_summary(w.h, 0, 1, 1, 1, nullptr, 0), 1); +} + +TEST(OakAudioWaveform, OverwriteSamplesErrorPaths) +{ + WaveformHandle w; + float v = 0.0f; + const float *planes[1] = { &v }; + + // Channel count not set + EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, planes, 16, 48000, 0, 1), + OAKAUDIO_E_STATE); + + ASSERT_EQ(oakaudio_waveform_set_channel_count(w.h, 1), OAKAUDIO_OK); + // Zero denominator + EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, planes, 16, 48000, 0, 0), + OAKAUDIO_E_INVALID); + // NULL planes / bad counts + EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, nullptr, 16, 48000, 0, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_overwrite_samples(w.h, planes, 0, 48000, 0, 1), + OAKAUDIO_E_INVALID); + + OakAudioWaveform empty = {}; + EXPECT_EQ(oakaudio_waveform_overwrite_samples(empty, planes, 16, 48000, 0, + 1), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioWaveform, OverwriteSilenceAndTrim) +{ + WaveformHandle w; + ASSERT_EQ(oakaudio_waveform_set_channel_count(w.h, 1), OAKAUDIO_OK); + + // 2 seconds of silence + EXPECT_EQ(oakaudio_waveform_overwrite_silence(w.h, 0, 1, 2, 1), OAKAUDIO_OK); + int64_t num, den; + ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK); + EXPECT_NEAR(double(num) / double(den), 2.0, 1e-9); + + oakaudio_min_max pairs[1]; + ASSERT_EQ(oakaudio_waveform_get_summary(w.h, 0, 1, 2, 1, pairs, 1), 1); + EXPECT_FLOAT_EQ(pairs[0].min, 0.0f); + EXPECT_FLOAT_EQ(pairs[0].max, 0.0f); + + // Trim away the first second + EXPECT_EQ(oakaudio_waveform_trim_in(w.h, 1, 1), OAKAUDIO_OK); + ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK); + EXPECT_NEAR(double(num) / double(den), 1.0, 1e-9); + + // Resize to half a second + EXPECT_EQ(oakaudio_waveform_resize(w.h, 1, 2), OAKAUDIO_OK); + ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK); + EXPECT_NEAR(double(num) / double(den), 0.5, 1e-9); + + // trim_range: in 0, length 1s + EXPECT_EQ(oakaudio_waveform_trim_range(w.h, 0, 1, 1, 1), OAKAUDIO_OK); + ASSERT_EQ(oakaudio_waveform_length(w.h, &num, &den), OAKAUDIO_OK); + EXPECT_NEAR(double(num) / double(den), 1.0, 1e-9); + + // Error paths + EXPECT_EQ(oakaudio_waveform_overwrite_silence(w.h, 0, 0, 1, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_trim_in(w.h, 1, 0), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_resize(w.h, 1, 0), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_trim_range(w.h, 0, 1, 1, 0), OAKAUDIO_E_INVALID); + OakAudioWaveform empty = {}; + EXPECT_EQ(oakaudio_waveform_trim_in(empty, 1, 1), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_resize(empty, 1, 1), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_trim_range(empty, 0, 1, 1, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_overwrite_silence(empty, 0, 1, 1, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_get_summary(empty, 0, 1, 1, 1, pairs, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_get_summary(w.h, 0, 1, 1, 0, pairs, 1), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioWaveform, OverwriteSums) +{ + WaveformHandle src, dst; + ASSERT_EQ(oakaudio_waveform_set_channel_count(src.h, 1), OAKAUDIO_OK); + ASSERT_EQ(oakaudio_waveform_set_channel_count(dst.h, 1), OAKAUDIO_OK); + + std::vector data(48000, 0.25f); + const float *planes[1] = { data.data() }; + ASSERT_EQ(oakaudio_waveform_overwrite_samples(src.h, planes, 48000, 48000, + 0, 1), + OAKAUDIO_OK); + + // Copy all of src into dst at t=0 + EXPECT_EQ(oakaudio_waveform_overwrite_sums(dst.h, src.h, 0, 1, 0, 1, 0, 1), + OAKAUDIO_OK); + int64_t num, den; + ASSERT_EQ(oakaudio_waveform_length(dst.h, &num, &den), OAKAUDIO_OK); + EXPECT_NEAR(double(num) / double(den), 1.0, 1e-9); + + oakaudio_min_max pairs[1]; + ASSERT_EQ(oakaudio_waveform_get_summary(dst.h, 0, 1, 1, 1, pairs, 1), 1); + EXPECT_FLOAT_EQ(pairs[0].max, 0.25f); + + // Error paths + OakAudioWaveform empty = {}; + EXPECT_EQ(oakaudio_waveform_overwrite_sums(dst.h, empty, 0, 1, 0, 1, 0, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_overwrite_sums(empty, src.h, 0, 1, 0, 1, 0, 1), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_overwrite_sums(dst.h, src.h, 0, 0, 0, 1, 0, 1), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioWaveform, SumSamplesStatic) +{ + std::vector ch0 = { 0.1f, -0.4f, 0.3f, 0.2f }; + std::vector ch1 = { -0.9f, 0.5f, 0.1f, 0.0f }; + const float *planes[2] = { ch0.data(), ch1.data() }; + oakaudio_min_max out[2]; + + EXPECT_EQ(oakaudio_waveform_sum_samples_s(planes, 2, 0, 4, out), + OAKAUDIO_OK); + EXPECT_FLOAT_EQ(out[0].min, -0.4f); + EXPECT_FLOAT_EQ(out[0].max, 0.3f); + EXPECT_FLOAT_EQ(out[1].min, -0.9f); + EXPECT_FLOAT_EQ(out[1].max, 0.5f); + + // Error paths + EXPECT_EQ(oakaudio_waveform_sum_samples_s(nullptr, 2, 0, 4, out), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_sum_samples_s(planes, 0, 0, 4, out), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_sum_samples_s(planes, 2, 0, 0, out), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_sum_samples_s(planes, 2, 0, 4, nullptr), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioWaveform, ReSumStatic) +{ + oakaudio_min_max in[4] = { { -0.5f, 0.4f }, { -0.2f, 0.9f }, + { -0.7f, 0.1f }, { 0.0f, 0.3f } }; + oakaudio_min_max out[2]; + + EXPECT_EQ(oakaudio_waveform_re_sum_s(in, 4, 2, out), OAKAUDIO_OK); + EXPECT_FLOAT_EQ(out[0].min, -0.7f); + EXPECT_FLOAT_EQ(out[0].max, 0.4f); + EXPECT_FLOAT_EQ(out[1].min, -0.2f); + EXPECT_FLOAT_EQ(out[1].max, 0.9f); + + // Error paths + EXPECT_EQ(oakaudio_waveform_re_sum_s(nullptr, 4, 2, out), + OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_re_sum_s(in, 0, 2, out), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_re_sum_s(in, 4, 0, out), OAKAUDIO_E_INVALID); + EXPECT_EQ(oakaudio_waveform_re_sum_s(in, 4, 2, nullptr), + OAKAUDIO_E_INVALID); +} + +TEST(OakAudioWaveform, ExtractFromMediaFile) +{ + const std::string file = demo_file(); + + // Probe the expected duration independently + OakDecoder probe = oakcodec_decoder_probe(file.c_str()); + ASSERT_NE(probe.ctx, nullptr) << "demo.mp4 not decodable"; + oakcodec_audio_stream_info info; + ASSERT_EQ(oakcodec_decoder_probe_get_audio_stream(probe, 0, &info), + OAKCODEC_OK); + // The audio stream's duration_ts is not populated for this file; the + // video stream carries the clip duration. + oakcodec_video_stream_info vinfo; + double duration = 0.0; + if (oakcodec_decoder_probe_get_video_stream(probe, 0, &vinfo) == + OAKCODEC_OK && + vinfo.time_base_den > 0) { + duration = double(vinfo.duration_ts) * vinfo.time_base_num / + vinfo.time_base_den; + } + oakcodec_decoder_free(&probe); + ASSERT_GT(duration, 0.0); + + const int before = oakaudio_debug_alive_count(); + + constexpr int kSamplesPerPoint = 1024; + // Two-stage sizing: NULL out returns the required point count + const int required = oakaudio_waveform_extract( + file.c_str(), 0, kSamplesPerPoint, nullptr, 0, nullptr); + ASSERT_GT(required, 0); + + std::vector pairs(size_t(required) * 4); + int channels = 0; + const int points = + oakaudio_waveform_extract(file.c_str(), 0, kSamplesPerPoint, + pairs.data(), int(pairs.size()), &channels); + ASSERT_EQ(points, required); + EXPECT_EQ(channels, info.channel_count); + + // Length consistency with the stream duration (generous tolerance for + // container/decoder rounding) + const double covered = + double(points) * kSamplesPerPoint / info.sample_rate; + EXPECT_NEAR(covered, duration, std::max(0.5, duration * 0.1)); + + // Non-trivial content: at least one point carries signal + bool any_signal = false; + for (int i = 0; i < points * channels; i++) { + if (pairs[size_t(i)].max > 0.0f || pairs[size_t(i)].min < 0.0f) { + any_signal = true; + break; + } + } + EXPECT_TRUE(any_signal); + + EXPECT_EQ(oakaudio_debug_alive_count(), before); +} + +TEST(OakAudioWaveform, ExtractErrorPaths) +{ + int channels = 0; + oakaudio_min_max pairs[8]; + + // Nonexistent file + EXPECT_EQ(oakaudio_waveform_extract("/nonexistent/file.mp4", 0, 1024, + pairs, 8, &channels), + OAKAUDIO_E_NOT_FOUND); + // NULL filename + EXPECT_EQ(oakaudio_waveform_extract(nullptr, 0, 1024, pairs, 8, &channels), + OAKAUDIO_E_INVALID); + // Bad stream index + EXPECT_EQ(oakaudio_waveform_extract(demo_file().c_str(), 99, 1024, pairs, 8, + &channels), + OAKAUDIO_E_NOT_FOUND); + // Bad samples-per-point + EXPECT_EQ(oakaudio_waveform_extract(demo_file().c_str(), 0, 0, pairs, 8, + &channels), + OAKAUDIO_E_INVALID); +} diff --git a/src/codec/NOTES.md b/src/codec/NOTES.md index 90e837d02..a32285626 100644 --- a/src/codec/NOTES.md +++ b/src/codec/NOTES.md @@ -8,9 +8,11 @@ `k_proxy_missing`,不崩溃不阻塞。注册语义为同步提交(回调内完成或 排队后立即返回);`SubmitTask` 持锁调回调,回调内不可重入注册函数。 conform/proxy 任务的 working→finished 改名生命周期整体移交 M8 oaktask。 -2. **Config**(config 里程碑收口):`ProxyManager::proxy_params_from_config()` - 返回编译期默认值(1280x720/div1/mp4/crf23/veryfast/含音频);未引入 - 内存态 stub(ffmpegencoder 当前版本已不读 Config)。 +2. **Config**(config 波次已收口):`ProxyManager::proxy_params_from_config()` + 现经 `oakcommon_config_*` C ABI 读取 ProxyWidth/ProxyHeight/ProxyDivider/ + ProxyCRF/ProxyPreset/ProxyIncludeAudio,ProxyParams 成员默认值兼作 + getter fallback(与 oakcommon 编译期默认值一致:1280x720/div1/crf23/ + veryfast/含音频)。 3. **纹理路径功能回退**(oakrender 增补 shader-blit C API 后可恢复): oakrender C API 无通用 shader-blit,FFmpegDecoder 的 yuv2rgb GLSL 路径与 去隔行 shader 路径已删除;YUV 帧改在 CPU 上 swscale 转 RGBA 后 diff --git a/src/codec/src/proxymanager.cpp b/src/codec/src/proxymanager.cpp index 2d9384ab7..cc8db5225 100644 --- a/src/codec/src/proxymanager.cpp +++ b/src/codec/src/proxymanager.cpp @@ -24,6 +24,7 @@ #include +#include "common/config.h" #include "common/filefunctions.h" #include "taskcallbacks.h" @@ -188,10 +189,30 @@ bool ProxyManager::proxy_filename_has_audio(const std::string &proxy_filename) ProxyManager::ProxyParams ProxyManager::proxy_params_from_config() { - // Interim state: the Qt config store (OAK_CONFIG ProxyWidth/ProxyHeight/ - // ProxyDivider/ProxyCRF/ProxyPreset/ProxyIncludeAudio) is not split yet, - // so the compiled-in defaults apply. - return ProxyParams(); + // config 波次: reads go through the oakcommon_config_* C ABI. The + // ProxyParams member defaults double as the getter fallbacks (the same + // values are also registered as compiled-in config defaults). + ProxyParams params; + params.width = + oakcommon_config_get_int(nullptr, "ProxyWidth", params.width); + params.height = + oakcommon_config_get_int(nullptr, "ProxyHeight", params.height); + params.divider = + oakcommon_config_get_int(nullptr, "ProxyDivider", params.divider); + params.crf = oakcommon_config_get_int(nullptr, "ProxyCRF", params.crf); + params.include_audio = + oakcommon_config_get_bool(nullptr, "ProxyIncludeAudio", + params.include_audio ? 1 : 0) != 0; + + const int preset_size = + oakcommon_config_get(nullptr, "ProxyPreset", nullptr, 0); + if (preset_size > 0) { + std::string preset(preset_size - 1, '\0'); + oakcommon_config_get(nullptr, "ProxyPreset", preset.data(), + preset_size); + params.preset = preset; + } + return params; } std::string ProxyManager::find_f_fmpeg_executable(const std::string &configured_path) diff --git a/src/common/c_api/CMakeLists.txt b/src/common/c_api/CMakeLists.txt index 7f670150d..cc354af7f 100644 --- a/src/common/c_api/CMakeLists.txt +++ b/src/common/c_api/CMakeLists.txt @@ -1,5 +1,6 @@ target_sources(oakcommon PRIVATE colortransform.cpp + config.cpp commandlineparser.cpp current.cpp subtitleparams.cpp diff --git a/src/common/c_api/config.cpp b/src/common/c_api/config.cpp new file mode 100644 index 000000000..1cbcfc7e1 --- /dev/null +++ b/src/common/c_api/config.cpp @@ -0,0 +1,314 @@ +/*** + + 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 "common/config.h" + +#include +#include + +#include "../src/configstore.h" + +namespace +{ + +std::mutex handler_mutex; +OakCommonConfigErrorHandler error_handler = nullptr; +void *error_handler_userdata = nullptr; + +bool is_valid_key(const char *key) +{ + return key != nullptr && key[0] != '\0'; +} + +bool is_valid_string_out(char *buf, int buf_size) +{ + return buf_size >= 0 && (buf_size == 0 || buf != nullptr); +} + +int write_string_result(const std::string &value, char *buf, int buf_size) +{ + int required = static_cast(value.size()) + 1; + if (buf != nullptr && buf_size >= required) { + memcpy(buf, value.c_str(), required); + } + return required; +} + +int to_c_type(ConfigStore::Type type) +{ + switch (type) { + case ConfigStore::Type::k_string: + return OAKCOMMON_CONFIG_ENTRY_STRING; + case ConfigStore::Type::k_int: + return OAKCOMMON_CONFIG_ENTRY_INT; + case ConfigStore::Type::k_double: + return OAKCOMMON_CONFIG_ENTRY_DOUBLE; + case ConfigStore::Type::k_bool: + return OAKCOMMON_CONFIG_ENTRY_BOOL; + default: + return OAKCOMMON_CONFIG_ENTRY_NONE; + } +} + +} // namespace + +int oakcommon_config_load(void) +{ + try { + return ConfigStore::current().load() ? OAKCOMMON_OK + : OAKCOMMON_E_FAILED; + } catch (...) { + return OAKCOMMON_E_FAILED; + } +} + +int oakcommon_config_save(void) +{ + try { + return ConfigStore::current().save() ? OAKCOMMON_OK + : OAKCOMMON_E_FAILED; + } catch (...) { + return OAKCOMMON_E_FAILED; + } +} + +int oakcommon_config_reset_defaults(void) +{ + try { + ConfigStore::current().set_defaults(); + return OAKCOMMON_OK; + } catch (...) { + return OAKCOMMON_E_FAILED; + } +} + +void oakcommon_config_set(const char *group, const char *key, + const char *value_utf8) +{ + if (!is_valid_key(key) || value_utf8 == nullptr) { + return; + } + + try { + ConfigStore &store = ConfigStore::current(); + const std::string joined = ConfigStore::join_key(group, key); + const ConfigStore::Entry *existing = store.get(joined); + if (existing == nullptr || + existing->type == ConfigStore::Type::k_string) { + ConfigStore::Entry e; + e.type = ConfigStore::Type::k_string; + e.string_value = value_utf8; + store.set(joined, e); + return; + } + + // Existing typed entry: parse the string into its declared type; + // an unparseable value leaves the entry unchanged. + ConfigStore::Entry parsed; + if (ConfigStore::string_to_value(value_utf8, existing->type, + &parsed)) { + store.set(joined, parsed); + } + } catch (...) { + // §2.1 setters return void; allocation failures are swallowed. + } +} + +int oakcommon_config_get(const char *group, const char *key, char *buf, + int buf_size) +{ + if (!is_valid_key(key) || !is_valid_string_out(buf, buf_size)) { + return OAKCOMMON_E_INVALID; + } + + try { + const ConfigStore::Entry *entry = + ConfigStore::current().get(ConfigStore::join_key(group, key)); + if (entry == nullptr) { + return OAKCOMMON_E_NOT_FOUND; + } + return write_string_result(ConfigStore::value_to_string(*entry), buf, + buf_size); + } catch (...) { + return OAKCOMMON_E_FAILED; + } +} + +int oakcommon_config_get_int(const char *group, const char *key, + int fallback) +{ + return static_cast( + oakcommon_config_get_int64(group, key, fallback)); +} + +int64_t oakcommon_config_get_int64(const char *group, const char *key, + int64_t fallback) +{ + if (!is_valid_key(key)) { + return fallback; + } + + try { + const ConfigStore::Entry *entry = + ConfigStore::current().get(ConfigStore::join_key(group, key)); + if (entry == nullptr || entry->type != ConfigStore::Type::k_int) { + return fallback; + } + return entry->int_value; + } catch (...) { + return fallback; + } +} + +void oakcommon_config_set_int(const char *group, const char *key, int v) +{ + oakcommon_config_set_int64(group, key, v); +} + +void oakcommon_config_set_int64(const char *group, const char *key, + int64_t v) +{ + if (!is_valid_key(key)) { + return; + } + + try { + ConfigStore::Entry e; + e.type = ConfigStore::Type::k_int; + e.int_value = v; + ConfigStore::current().set(ConfigStore::join_key(group, key), e); + } catch (...) { + } +} + +double oakcommon_config_get_double(const char *group, const char *key, + double fallback) +{ + if (!is_valid_key(key)) { + return fallback; + } + + try { + const ConfigStore::Entry *entry = + ConfigStore::current().get(ConfigStore::join_key(group, key)); + if (entry == nullptr || entry->type != ConfigStore::Type::k_double) { + return fallback; + } + return entry->double_value; + } catch (...) { + return fallback; + } +} + +void oakcommon_config_set_double(const char *group, const char *key, + double v) +{ + if (!is_valid_key(key)) { + return; + } + + try { + ConfigStore::Entry e; + e.type = ConfigStore::Type::k_double; + e.double_value = v; + ConfigStore::current().set(ConfigStore::join_key(group, key), e); + } catch (...) { + } +} + +int oakcommon_config_get_bool(const char *group, const char *key, + int fallback) +{ + if (!is_valid_key(key)) { + return fallback; + } + + try { + const ConfigStore::Entry *entry = + ConfigStore::current().get(ConfigStore::join_key(group, key)); + if (entry == nullptr || entry->type != ConfigStore::Type::k_bool) { + return fallback; + } + return entry->bool_value ? 1 : 0; + } catch (...) { + return fallback; + } +} + +void oakcommon_config_set_bool(const char *group, const char *key, int v) +{ + if (!is_valid_key(key)) { + return; + } + + try { + ConfigStore::Entry e; + e.type = ConfigStore::Type::k_bool; + e.bool_value = v != 0; + ConfigStore::current().set(ConfigStore::join_key(group, key), e); + } catch (...) { + } +} + +int oakcommon_config_entry_type(const char *group, const char *key) +{ + if (!is_valid_key(key)) { + return OAKCOMMON_E_INVALID; + } + + try { + const ConfigStore::Entry *entry = + ConfigStore::current().get(ConfigStore::join_key(group, key)); + if (entry == nullptr) { + return OAKCOMMON_E_NOT_FOUND; + } + return to_c_type(entry->type); + } catch (...) { + return OAKCOMMON_E_FAILED; + } +} + +int oakcommon_config_set_error_handler(OakCommonConfigErrorHandler handler, + void *userdata) +{ + try { + { + std::lock_guard lock(handler_mutex); + error_handler = handler; + error_handler_userdata = userdata; + } + + if (handler != nullptr) { + ConfigStore::set_error_handler( + [](const std::string &title, const std::string &message) { + std::lock_guard lock(handler_mutex); + if (error_handler != nullptr) { + error_handler(title.c_str(), message.c_str(), + error_handler_userdata); + } + }); + } else { + ConfigStore::set_error_handler(nullptr); + } + return OAKCOMMON_OK; + } catch (...) { + return OAKCOMMON_E_FAILED; + } +} diff --git a/src/common/src/CMakeLists.txt b/src/common/src/CMakeLists.txt index 3762a5c56..25d248ca2 100644 --- a/src/common/src/CMakeLists.txt +++ b/src/common/src/CMakeLists.txt @@ -19,6 +19,8 @@ add_library(oakcommon SHARED commandlineparser.cpp commandlineparser.h colortransform.h + configstore.cpp + configstore.h current.cpp current.h debug.cpp diff --git a/src/common/src/configstore.cpp b/src/common/src/configstore.cpp new file mode 100644 index 000000000..9e3c280e3 --- /dev/null +++ b/src/common/src/configstore.cpp @@ -0,0 +1,406 @@ +/*** + + 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 "configstore.h" + +#include +#include +#include +#include +#include + +#include "filefunctions.h" + +namespace fs = std::filesystem; + +ConfigStore::ErrorHandler ConfigStore::error_handler_ = nullptr; + +ConfigStore &ConfigStore::current() +{ + static ConfigStore store; + return store; +} + +ConfigStore::ConfigStore() +{ + set_defaults(); +} + +void ConfigStore::set_error_handler(ErrorHandler handler) +{ + error_handler_ = std::move(handler); +} + +void ConfigStore::report_error(const std::string &title, + const std::string &message) +{ + if (error_handler_) { + error_handler_(title, message); + } else { + fprintf(stderr, "%s: %s\n", title.c_str(), message.c_str()); + } +} + +std::string ConfigStore::get_config_file_path() +{ + return (fs::path(FileFunctions::get_configuration_location()) / + "config.ini") + .string(); +} + +std::string ConfigStore::join_key(const char *group, const char *key) +{ + if (group != nullptr && group[0] != '\0') { + return std::string(group) + "/" + key; + } + return key; +} + +void ConfigStore::set_defaults() +{ + std::lock_guard lock(mutex_); + config_map_.clear(); + + auto set_string = [this](const char *key, const char *value) { + Entry e; + e.type = Type::k_string; + e.string_value = value; + config_map_[key] = e; + }; + auto set_int = [this](const char *key, int64_t value) { + Entry e; + e.type = Type::k_int; + e.int_value = value; + config_map_[key] = e; + }; + auto set_bool = [this](const char *key, bool value) { + Entry e; + e.type = Type::k_bool; + e.bool_value = value; + config_map_[key] = e; + }; + + // Only the keys the de-Qt engine modules (oaknode/oakrender/oakcodec) + // actually read are registered here; the app-layer keys of the old Qt + // config arrive with the app/config wave. Enum-valued ints hardcode + // the numeric values of their (still Qt-based) defining headers: + // + // - Timeline::k_thumbnail_in_out / k_waveforms_enabled = 1 + // (engine/timeline/timelinecommon.h) + // - PixelFormat::f32 = 4 (core/include/olive/core/render/pixelformat.h) + // - VideoParams::k_interlace_none = 0 (src/common/src/videoparams.h) + // - k_channel_layout_stereo = 3 + // (core/include/olive/core/render/channellayout.h) + // - ColorCoding::k_red..k_navy = 0..11, k_lime = 6 + // (engine/ui/colorcoding.h) + + set_int("TimelineThumbnailMode", 1); + set_int("TimelineWaveformMode", 1); + + set_int("DefaultSequenceWidth", 1920); + set_int("DefaultSequenceHeight", 1080); + // Rational settings are stored as strings in oakcore_rational + // "num/den" form; this mirrors the old default Rational(1001, 30000). + set_string("DefaultSequenceFrameRate", "1001/30000"); + set_string("DefaultSequencePixelAspect", "1/1"); + set_int("DefaultSequenceInterlacing", 0); + set_int("DefaultSequenceAudioFrequency", 48000); + set_int("DefaultSequenceAudioLayout", 3); + set_int("OfflinePixelFormat", 4); + + set_bool("SplitClipsCopyNodes", true); + set_bool("UseProxyMedia", true); + set_bool("UseGLFinish", false); + set_bool("ReassocLinToNonLin", false); + + set_string("GraphicsBackend", "opengl"); + set_string("LUTLibraryPaths", ""); + + set_int("DiskCacheSaveInterval", 10000); + set_int("AutoCacheDelay", 1000); + set_string("DiskCacheBehind", "0/1"); + set_string("DiskCacheAhead", "60/1"); + + set_int("ProxyWidth", 1280); + set_int("ProxyHeight", 720); + set_int("ProxyDivider", 1); + set_int("ProxyCRF", 23); + set_string("ProxyPreset", "veryfast"); + set_bool("ProxyIncludeAudio", true); + + set_int("MarkerColor", 6); + for (int i = 0; i <= 11; i++) { + set_int(("CatColor" + std::to_string(i)).c_str(), i); + } +} + +std::string ConfigStore::value_to_string(const Entry &entry) +{ + switch (entry.type) { + case Type::k_string: + return entry.string_value; + case Type::k_int: + return std::to_string(entry.int_value); + case Type::k_double: { + char buf[64]; + snprintf(buf, sizeof(buf), "%g", entry.double_value); + return buf; + } + case Type::k_bool: + return entry.bool_value ? "true" : "false"; + default: + return std::string(); + } +} + +bool ConfigStore::string_to_value(const std::string &text, Type type, + Entry *out) +{ + Entry e; + e.type = type; + + switch (type) { + case Type::k_string: + e.string_value = text; + break; + case Type::k_int: { + try { + size_t pos = 0; + e.int_value = std::stoll(text, &pos); + if (pos != text.size()) { + return false; + } + } catch (...) { + return false; + } + break; + } + case Type::k_double: { + try { + size_t pos = 0; + e.double_value = std::stod(text, &pos); + if (pos != text.size()) { + return false; + } + } catch (...) { + return false; + } + break; + } + case Type::k_bool: + if (text == "true" || text == "1") { + e.bool_value = true; + } else if (text == "false" || text == "0") { + e.bool_value = false; + } else { + return false; + } + break; + default: + return false; + } + + *out = e; + return true; +} + +namespace +{ + +std::string trim(const std::string &s) +{ + const size_t first = s.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) { + return std::string(); + } + const size_t last = s.find_last_not_of(" \t\r\n"); + return s.substr(first, last - first + 1); +} + +} // namespace + +bool ConfigStore::load() +{ + set_defaults(); + + const std::string path = get_config_file_path(); + + std::error_code ec; + if (!fs::exists(path, ec)) { + // No saved settings yet: defaults are fine, not an error. + return true; + } + + // exists() also covers directories, which ifstream would happily + // "open" on POSIX; only a regular file is a readable config. + if (!fs::is_regular_file(path, ec)) { + report_error("Error loading settings", + "Failed to load application settings. This session will " + "use defaults."); + return false; + } + + std::ifstream in(path); + if (!in.is_open()) { + report_error("Error loading settings", + "Failed to load application settings. This session will " + "use defaults."); + return false; + } + + std::string group; + std::string line; + while (std::getline(in, line)) { + line = trim(line); + if (line.empty() || line.front() == ';' || line.front() == '#') { + continue; + } + + if (line.front() == '[' && line.back() == ']') { + group = trim(line.substr(1, line.size() - 2)); + continue; + } + + const size_t eq = line.find('='); + if (eq == std::string::npos) { + // Malformed line: skip, keep going (matches QSettings' lax + // INI parsing). + continue; + } + + std::string key = trim(line.substr(0, eq)); + const std::string value = trim(line.substr(eq + 1)); + if (key.empty()) { + continue; + } + if (!group.empty()) { + key = group + "/" + key; + } + + Entry parsed; + const Entry *existing = get(key); + if (existing != nullptr) { + // Known key: honor its declared type. An unparseable value + // keeps the default. + if (string_to_value(value, existing->type, &parsed)) { + set(key, parsed); + } + } else { + // Unknown key: stored as a string. + parsed.type = Type::k_string; + parsed.string_value = value; + set(key, parsed); + } + } + + return true; +} + +bool ConfigStore::save() +{ + const std::string real_filename = get_config_file_path(); + const std::string temp_filename = real_filename + ".tmp"; + + // Flat keys are written at the top level; keys containing '/' become + // [group] sections (group = everything before the last '/'), keeping + // the QSettings INI key shape. + std::map> sections; + { + std::lock_guard lock(mutex_); + for (const auto &pair : config_map_) { + const std::string &key = pair.first; + const size_t slash = key.rfind('/'); + std::string group = slash == std::string::npos + ? std::string() + : key.substr(0, slash); + std::string sub = slash == std::string::npos + ? key + : key.substr(slash + 1); + sections[group][sub] = value_to_string(pair.second); + } + } + + { + std::ofstream out(temp_filename, std::ios::trunc); + if (!out.is_open()) { + report_error("Error saving settings", + "Failed to save application settings. The " + "application may lack write permissions for this " + "location."); + return false; + } + + const auto flat = sections.find(std::string()); + if (flat != sections.end()) { + for (const auto &pair : flat->second) { + out << pair.first << '=' << pair.second << '\n'; + } + } + + for (const auto §ion : sections) { + if (section.first.empty()) { + continue; + } + out << '\n' + << '[' << section.first << ']' << '\n'; + for (const auto &pair : section.second) { + out << pair.first << '=' << pair.second << '\n'; + } + } + + out.flush(); + if (!out.good()) { + report_error("Error saving settings", + "Failed to save application settings. The " + "application may lack write permissions for this " + "location."); + return false; + } + } + + std::error_code ec; + fs::rename(temp_filename, real_filename, ec); + if (ec) { + fs::remove(real_filename, ec); + ec.clear(); + fs::rename(temp_filename, real_filename, ec); + if (ec) { + report_error("Error saving settings", + "Failed to overwrite the application settings " + "file."); + return false; + } + } + + return true; +} + +const ConfigStore::Entry *ConfigStore::get(const std::string &key) const +{ + std::lock_guard lock(mutex_); + auto it = config_map_.find(key); + return it == config_map_.end() ? nullptr : &it->second; +} + +void ConfigStore::set(const std::string &key, const Entry &entry) +{ + std::lock_guard lock(mutex_); + config_map_[key] = entry; +} diff --git a/src/common/src/configstore.h b/src/common/src/configstore.h new file mode 100644 index 000000000..5fd8c7399 --- /dev/null +++ b/src/common/src/configstore.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_CONFIGSTORE_H +#define OAK_CONFIGSTORE_H + +#include +#include +#include +#include +#include + +/** + * @brief De-Qt application configuration store (process singleton) + * + * Replacement for the Qt-based olive::Config (engine/config/config.h): + * QMap/QVariant became std::map + a small typed value, QSettings became a + * self-written INI file, and NodeValue::Type became ConfigStore::Type so + * config no longer depends on the node module. + * + * Keys keep the QSettings INI shape: "group/key" maps to an INI [group] + * section; flat keys stay at the top level. The file lives at + * /config.ini (the + * OAK_CONFIG_DIR override applies, which is what tests use). + * + * All public methods are thread-safe (single mutex). + */ +class ConfigStore { +public: + enum class Type { k_none, k_string, k_int, k_double, k_bool }; + + struct Entry { + Type type = Type::k_none; + std::string string_value; + int64_t int_value = 0; + double double_value = 0.0; + bool bool_value = false; + }; + + using ErrorHandler = + std::function; + + static ConfigStore ¤t(); + + /** + * @brief Resets the store to compiled-in defaults (drops custom keys) + */ + void set_defaults(); + + /** + * @brief Resets to defaults, then applies config.ini if it exists + * + * @return false when the file exists but could not be read (the error + * is also reported through the registered error handler). + */ + bool load(); + + /** + * @brief Writes the store to config.ini via temp file + rename + * + * @return false on failure (also reported through the error handler). + */ + bool save(); + + /** + * @brief Returns the entry for key, or nullptr when absent + * + * Keys are the joined "group/key" form (or the bare key when group is + * null/empty). + */ + const Entry *get(const std::string &key) const; + + /** + * @brief Creates or replaces an entry + */ + void set(const std::string &key, const Entry &entry); + + static void set_error_handler(ErrorHandler handler); + static void report_error(const std::string &title, + const std::string &message); + + /** + * @brief /config.ini + */ + static std::string get_config_file_path(); + + /** + * @brief Joins group and key into the stored "group/key" form + */ + static std::string join_key(const char *group, const char *key); + + /** + * @brief Serializes an entry for the INI file / string getter + */ + static std::string value_to_string(const Entry &entry); + + /** + * @brief Parses text into an entry of the given type + * + * @return false when the text cannot be parsed as the requested type + * (strings always parse). + */ + static bool string_to_value(const std::string &text, Type type, + Entry *out); + +private: + ConfigStore(); + + std::map config_map_; + mutable std::mutex mutex_; + + static ErrorHandler error_handler_; +}; + +#endif // OAK_CONFIGSTORE_H diff --git a/src/common/tests/CMakeLists.txt b/src/common/tests/CMakeLists.txt index e4fa8c00a..3af282246 100644 --- a/src/common/tests/CMakeLists.txt +++ b/src/common/tests/CMakeLists.txt @@ -21,6 +21,7 @@ include(GoogleTest) add_executable(oakcommon-gtest colortransform_test.cpp commandlineparser_test.cpp + config_test.cpp current_test.cpp debug_test.cpp dropworkflowbehavior_test.cpp diff --git a/src/common/tests/config_test.cpp b/src/common/tests/config_test.cpp new file mode 100644 index 000000000..86cdc4a78 --- /dev/null +++ b/src/common/tests/config_test.cpp @@ -0,0 +1,384 @@ +/*** + + 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 "common/config.h" + +#include +#include +#include +#include +#include + +#include + +namespace +{ + +namespace fs = std::filesystem; + +/** + * @brief Redirects the config file into a fresh per-test temp directory + * + * ConfigStore resolves its file through + * FileFunctions::get_configuration_location() on every load/save, and + * that honors OAK_CONFIG_DIR, so setting the env var per test isolates + * the on-disk state. The store itself is reset to defaults in SetUp. + */ +class ConfigTest : public testing::Test { +protected: + void SetUp() override + { + dir_ = fs::temp_directory_path() / + fs::path("oakconfig_test_" + std::to_string( + ::testing::UnitTest::GetInstance() + ->random_seed()) + + "_" + std::to_string(counter_++)); + fs::create_directories(dir_); + setenv("OAK_CONFIG_DIR", dir_.string().c_str(), 1); + ASSERT_EQ(oakcommon_config_reset_defaults(), OAKCOMMON_OK); + ASSERT_EQ(oakcommon_config_set_error_handler(nullptr, nullptr), + OAKCOMMON_OK); + } + + void TearDown() override + { + unsetenv("OAK_CONFIG_DIR"); + oakcommon_config_reset_defaults(); + oakcommon_config_set_error_handler(nullptr, nullptr); + std::error_code ec; + fs::remove_all(dir_, ec); + } + + std::string ini_contents() + { + std::ifstream in((dir_ / "config.ini").string()); + return std::string(std::istreambuf_iterator(in), + std::istreambuf_iterator()); + } + + fs::path dir_; + static int counter_; +}; + +int ConfigTest::counter_ = 0; + +// --- compiled-in defaults ------------------------------------------------- + +TEST_F(ConfigTest, DefaultsAreRegistered) +{ + EXPECT_EQ(oakcommon_config_get_int(nullptr, "DefaultSequenceWidth", -1), + 1920); + EXPECT_EQ(oakcommon_config_get_bool(nullptr, "SplitClipsCopyNodes", -1), + 1); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "CatColor11", -1), 11); + + char buf[64]; + ASSERT_GT(oakcommon_config_get(nullptr, "GraphicsBackend", buf, + sizeof(buf)), + 0); + EXPECT_STREQ(buf, "opengl"); + + EXPECT_EQ(oakcommon_config_entry_type(nullptr, "DefaultSequenceWidth"), + OAKCOMMON_CONFIG_ENTRY_INT); + EXPECT_EQ(oakcommon_config_entry_type(nullptr, "GraphicsBackend"), + OAKCOMMON_CONFIG_ENTRY_STRING); + EXPECT_EQ(oakcommon_config_entry_type(nullptr, "UseProxyMedia"), + OAKCOMMON_CONFIG_ENTRY_BOOL); +} + +// --- oakcommon_config_set / oakcommon_config_get (string) ------------------ + +TEST_F(ConfigTest, SetGetStringRoundtripTwoStage) +{ + oakcommon_config_set(nullptr, "TestStringKey", "hello world"); + + // Stage 1: query the required size with a NULL buffer. + const int required = + oakcommon_config_get(nullptr, "TestStringKey", nullptr, 0); + ASSERT_EQ(required, int(strlen("hello world")) + 1); + + // Stage 2: fetch into a sufficiently large buffer. + std::string buf(required, '\0'); + ASSERT_EQ(oakcommon_config_get(nullptr, "TestStringKey", buf.data(), + required), + required); + EXPECT_STREQ(buf.c_str(), "hello world"); + + EXPECT_EQ(oakcommon_config_entry_type(nullptr, "TestStringKey"), + OAKCOMMON_CONFIG_ENTRY_STRING); +} + +TEST_F(ConfigTest, GetStringErrorPaths) +{ + char buf[8]; + // Missing key. + EXPECT_EQ(oakcommon_config_get(nullptr, "NoSuchKey", buf, sizeof(buf)), + OAKCOMMON_E_NOT_FOUND); + // NULL key. + EXPECT_EQ(oakcommon_config_get(nullptr, nullptr, buf, sizeof(buf)), + OAKCOMMON_E_INVALID); + // Negative buffer size. + EXPECT_EQ(oakcommon_config_get(nullptr, "GraphicsBackend", buf, -1), + OAKCOMMON_E_INVALID); + // set with NULL value is a no-op (void return, entry must not appear). + oakcommon_config_set(nullptr, "IgnoredKey", nullptr); + EXPECT_EQ(oakcommon_config_get(nullptr, "IgnoredKey", buf, sizeof(buf)), + OAKCOMMON_E_NOT_FOUND); +} + +// --- group/key (§2.1 two-argument form) ------------------------------------ + +TEST_F(ConfigTest, GroupedKeyMapsToIniSection) +{ + oakcommon_config_set("Audio", "Output", "coreaudio"); + + char buf[32]; + ASSERT_GT(oakcommon_config_get("Audio", "Output", buf, sizeof(buf)), 0); + EXPECT_STREQ(buf, "coreaudio"); + + ASSERT_EQ(oakcommon_config_save(), OAKCOMMON_OK); + const std::string ini = ini_contents(); + EXPECT_NE(ini.find("[Audio]"), std::string::npos); + EXPECT_NE(ini.find("Output=coreaudio"), std::string::npos); + + // Empty group behaves like NULL group (top-level key). + oakcommon_config_set("", "FlatKey", "flat"); + ASSERT_GT(oakcommon_config_get(nullptr, "FlatKey", buf, sizeof(buf)), 0); + EXPECT_STREQ(buf, "flat"); +} + +// --- int family ------------------------------------------------------------- + +TEST_F(ConfigTest, IntRoundtrip) +{ + oakcommon_config_set_int(nullptr, "TestIntKey", -42); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "TestIntKey", 0), -42); + EXPECT_EQ(oakcommon_config_entry_type(nullptr, "TestIntKey"), + OAKCOMMON_CONFIG_ENTRY_INT); + // Overrides a compiled-in default. + oakcommon_config_set_int(nullptr, "DefaultSequenceWidth", 3840); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "DefaultSequenceWidth", 0), + 3840); +} + +TEST_F(ConfigTest, IntFallbackOnMissingOrWrongType) +{ + // Missing key -> fallback. + EXPECT_EQ(oakcommon_config_get_int(nullptr, "NoSuchKey", 7), 7); + // Wrong type (string entry) -> fallback. + EXPECT_EQ(oakcommon_config_get_int(nullptr, "GraphicsBackend", 9), 9); +} + +TEST_F(ConfigTest, Int64RoundtripAndFallback) +{ + oakcommon_config_set_int64(nullptr, "TestInt64Key", + INT64_C(5000000000)); + EXPECT_EQ(oakcommon_config_get_int64(nullptr, "TestInt64Key", 0), + INT64_C(5000000000)); + // Missing key -> fallback; NULL key -> fallback. + EXPECT_EQ(oakcommon_config_get_int64(nullptr, "NoSuchKey", + INT64_C(-1)), + INT64_C(-1)); + EXPECT_EQ(oakcommon_config_get_int64(nullptr, nullptr, INT64_C(-2)), + INT64_C(-2)); +} + +// --- double family ---------------------------------------------------------- + +TEST_F(ConfigTest, DoubleRoundtrip) +{ + oakcommon_config_set_double(nullptr, "TestDoubleKey", 2.5); + EXPECT_DOUBLE_EQ(oakcommon_config_get_double(nullptr, "TestDoubleKey", 0), + 2.5); + EXPECT_EQ(oakcommon_config_entry_type(nullptr, "TestDoubleKey"), + OAKCOMMON_CONFIG_ENTRY_DOUBLE); +} + +TEST_F(ConfigTest, DoubleFallbackOnMissingOrWrongType) +{ + EXPECT_DOUBLE_EQ(oakcommon_config_get_double(nullptr, "NoSuchKey", 1.5), + 1.5); + // Wrong type (int entry) -> fallback. + EXPECT_DOUBLE_EQ( + oakcommon_config_get_double(nullptr, "DefaultSequenceWidth", 3.5), + 3.5); +} + +// --- bool family ------------------------------------------------------------ + +TEST_F(ConfigTest, BoolRoundtrip) +{ + oakcommon_config_set_bool(nullptr, "TestBoolKey", 1); + EXPECT_EQ(oakcommon_config_get_bool(nullptr, "TestBoolKey", 0), 1); + oakcommon_config_set_bool(nullptr, "TestBoolKey", 0); + EXPECT_EQ(oakcommon_config_get_bool(nullptr, "TestBoolKey", 1), 0); + EXPECT_EQ(oakcommon_config_entry_type(nullptr, "TestBoolKey"), + OAKCOMMON_CONFIG_ENTRY_BOOL); +} + +TEST_F(ConfigTest, BoolFallbackOnMissingOrWrongType) +{ + EXPECT_EQ(oakcommon_config_get_bool(nullptr, "NoSuchKey", 1), 1); + // Wrong type (string entry) -> fallback. + EXPECT_EQ(oakcommon_config_get_bool(nullptr, "GraphicsBackend", 1), 1); +} + +// --- typed set through oakcommon_config_set --------------------------------- + +TEST_F(ConfigTest, SetStringParsesIntoDeclaredType) +{ + // Existing INT entry: a parseable string updates the value... + oakcommon_config_set(nullptr, "DefaultSequenceWidth", "2560"); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "DefaultSequenceWidth", 0), + 2560); + // ...an unparseable one leaves the entry unchanged. + oakcommon_config_set(nullptr, "DefaultSequenceWidth", "not-a-number"); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "DefaultSequenceWidth", 0), + 2560); +} + +// --- load / save / reset_defaults ------------------------------------------- + +TEST_F(ConfigTest, SaveLoadRoundtrip) +{ + oakcommon_config_set_int(nullptr, "DefaultSequenceHeight", 2160); + oakcommon_config_set("Session", "LastDir", "/tmp/media"); + oakcommon_config_set_bool(nullptr, "UseGLFinish", 1); + ASSERT_EQ(oakcommon_config_save(), OAKCOMMON_OK); + ASSERT_TRUE(fs::exists(dir_ / "config.ini")); + + // Wipe in-memory state; defaults must come back... + ASSERT_EQ(oakcommon_config_reset_defaults(), OAKCOMMON_OK); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "DefaultSequenceHeight", 0), + 1080); + EXPECT_EQ(oakcommon_config_get_bool(nullptr, "UseGLFinish", -1), 0); + char buf[64]; + EXPECT_EQ(oakcommon_config_get("Session", "LastDir", buf, sizeof(buf)), + OAKCOMMON_E_NOT_FOUND); + + // ...and load() restores everything that was saved. + ASSERT_EQ(oakcommon_config_load(), OAKCOMMON_OK); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "DefaultSequenceHeight", 0), + 2160); + EXPECT_EQ(oakcommon_config_get_bool(nullptr, "UseGLFinish", -1), 1); + ASSERT_GT(oakcommon_config_get("Session", "LastDir", buf, sizeof(buf)), + 0); + EXPECT_STREQ(buf, "/tmp/media"); + EXPECT_EQ(oakcommon_config_entry_type("Session", "LastDir"), + OAKCOMMON_CONFIG_ENTRY_STRING); +} + +TEST_F(ConfigTest, LoadMissingFileIsNotAnError) +{ + // Fresh directory, no config.ini: defaults stay, OAKCOMMON_OK. + ASSERT_EQ(oakcommon_config_load(), OAKCOMMON_OK); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "DefaultSequenceWidth", -1), + 1920); +} + +TEST_F(ConfigTest, LoadErrorPathUnreadableFile) +{ + // Make config.ini a directory: it exists but cannot be read. + fs::create_directories(dir_ / "config.ini"); + EXPECT_EQ(oakcommon_config_load(), OAKCOMMON_E_FAILED); +} + +TEST_F(ConfigTest, ResetDefaultsDropsCustomKeys) +{ + oakcommon_config_set_int(nullptr, "TransientKey", 1); + ASSERT_EQ(oakcommon_config_get_int(nullptr, "TransientKey", -1), 1); + ASSERT_EQ(oakcommon_config_reset_defaults(), OAKCOMMON_OK); + EXPECT_EQ(oakcommon_config_get_int(nullptr, "TransientKey", -1), -1); + // Defaults survive the reset. + EXPECT_EQ(oakcommon_config_get_int(nullptr, "DefaultSequenceWidth", -1), + 1920); +} + +// --- error handler ---------------------------------------------------------- + +namespace +{ + +struct HandlerLog { + int calls = 0; + std::string title; + std::string message; +}; + +void recording_handler(const char *title, const char *message, + void *userdata) +{ + auto *log = static_cast(userdata); + log->calls++; + log->title = title; + log->message = message; +} + +} // namespace + +TEST_F(ConfigTest, ErrorHandlerFiresOnSaveFailure) +{ + HandlerLog log; + ASSERT_EQ( + oakcommon_config_set_error_handler(recording_handler, &log), + OAKCOMMON_OK); + + // Point the config dir at a path that exists as a *file*: the temp + // file cannot be created inside it, so save fails. + const fs::path blocker = fs::temp_directory_path() / + "oakconfig_test_blocker"; + { + std::ofstream out(blocker.string()); + out << "not a directory"; + } + setenv("OAK_CONFIG_DIR", blocker.string().c_str(), 1); + + EXPECT_EQ(oakcommon_config_save(), OAKCOMMON_E_FAILED); + EXPECT_EQ(log.calls, 1); + EXPECT_FALSE(log.title.empty()); + EXPECT_FALSE(log.message.empty()); + + // Restore the per-test dir for TearDown. + setenv("OAK_CONFIG_DIR", dir_.string().c_str(), 1); + std::error_code ec; + fs::remove(blocker, ec); +} + +TEST_F(ConfigTest, ClearErrorHandlerRestoresSilence) +{ + // Registering and clearing must both succeed. + ASSERT_EQ( + oakcommon_config_set_error_handler(recording_handler, nullptr), + OAKCOMMON_OK); + EXPECT_EQ(oakcommon_config_set_error_handler(nullptr, nullptr), + OAKCOMMON_OK); +} + +// --- entry_type error paths -------------------------------------------------- + +TEST_F(ConfigTest, EntryTypeErrorPaths) +{ + EXPECT_EQ(oakcommon_config_entry_type(nullptr, "NoSuchKey"), + OAKCOMMON_E_NOT_FOUND); + EXPECT_EQ(oakcommon_config_entry_type(nullptr, nullptr), + OAKCOMMON_E_INVALID); +} + +} // namespace diff --git a/src/node/DEQT.md b/src/node/DEQT.md index f5b1b5447..acfe2090f 100644 --- a/src/node/DEQT.md +++ b/src/node/DEQT.md @@ -257,8 +257,9 @@ cd src/node/src/node && c++ -std=c++17 -fsyntax-only -Wall \ add/remove_node 函数族)。 6. `factory.cpp` 的 `pluginSupport/` include、`traverser.h` 的 `"common/cancelableobject.h"` 原样保留(后者 oakcommon 没有,留 M 系列裁决)。 -7. `node.h` 仍 include `config/config.h`、`ui/colorcoding.h`(engine Qt 头,`color()` 用到 - `OAK_CONFIG_STR(...)`)——config 波次处理。 +7. ~~`node.h` 仍 include `config/config.h`~~ —— config 波次已处理:`configaccessor.h` + (src/node/src)替代 engine Qt 头与 transition stub,`OAK_CONFIG*` 宏在消费侧本地重定义为 + `oakcommon_config_*` C 调用,调用点零改动;`ui/colorcoding.h` 仍走 stub(UI 波次)。 8. `Node::gizmo_drag_move` 的 modifiers 参数为 `int`(原 `Qt::KeyboardModifiers`)。 9. timeformat 的 `format_date_time()` 不实现 `MMMM`(月名)/`dddd`(星期名)本地化 token (原默认格式 `hh:mm:ss` 用不到);负 epoch 毫秒的 `zzz` 取模与 Qt 有边界差异(实际输入不会触发)。 diff --git a/src/node/src/block/clip/clip.cpp b/src/node/src/block/clip/clip.cpp index b80bdb6b1..9f02cb4ae 100644 --- a/src/node/src/block/clip/clip.cpp +++ b/src/node/src/block/clip/clip.cpp @@ -24,7 +24,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" #include "block/transition/transition.h" #include "output/track/track.h" #include "output/viewer/viewer.h" diff --git a/src/node/src/color/colormanager/colormanager.cpp b/src/node/src/color/colormanager/colormanager.cpp index 0ffcf08ff..dcf0fe59b 100644 --- a/src/node/src/color/colormanager/colormanager.cpp +++ b/src/node/src/color/colormanager/colormanager.cpp @@ -28,7 +28,7 @@ #include "define.h" #include "filefunctions.h" -#include "config/config.h" +#include "configaccessor.h" #include "project.h" namespace olive diff --git a/src/node/src/configaccessor.h b/src/node/src/configaccessor.h new file mode 100644 index 000000000..7a64b6982 --- /dev/null +++ b/src/node/src/configaccessor.h @@ -0,0 +1,168 @@ +/*** + + 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_NODE_CONFIGACCESSOR_H +#define OAK_NODE_CONFIGACCESSOR_H + +/** + * @brief Consumer-side shim over the oakcommon_config_* C ABI + * + * config 波次: the old engine/config/config.h (Qt) and the in-memory + * transition/config/config.h stub are gone. This header keeps the + * OAK_CONFIG(...)/Config::current()[...] call shape so call sites did + * not change; every accessor is a C call into liboakcommon. Per the + * cross-module rule only the oakcommon C API is used here, never the + * ConfigStore C++ class. + */ + +#include +#include +#include + +#include "common/config.h" + +#ifndef QStringLiteral +#define QStringLiteral(x) x +#endif + +namespace olive +{ + +/** + * @brief Key-bound value proxy with the QVariant-subset accessors the + * call sites use + */ +class ConfigValue { +public: + ConfigValue() = default; + explicit ConfigValue(const std::string &key) + : key_(key) + { + } + + bool to_bool() const + { + return oakcommon_config_get_bool(nullptr, key_.c_str(), 0) != 0; + } + bool toBool() const { return to_bool(); } + + int to_int() const + { + return oakcommon_config_get_int(nullptr, key_.c_str(), 0); + } + int toInt() const { return to_int(); } + + int64_t to_long_long() const + { + return oakcommon_config_get_int64(nullptr, key_.c_str(), 0); + } + uint64_t to_u_long_long() const + { + return static_cast(to_long_long()); + } + uint64_t toULongLong() const { return to_u_long_long(); } + + double to_double() const + { + return oakcommon_config_get_double(nullptr, key_.c_str(), 0.0); + } + + std::string to_string() const + { + const int size = + oakcommon_config_get(nullptr, key_.c_str(), nullptr, 0); + if (size <= 0) { + return std::string(); + } + std::string s(size - 1, '\0'); + oakcommon_config_get(nullptr, key_.c_str(), s.data(), size); + return s; + } + std::string toString() const { return to_string(); } + + template T value() const { return value_impl(); } + + ConfigValue &operator=(const std::string &s) + { + oakcommon_config_set(nullptr, key_.c_str(), s.c_str()); + return *this; + } + ConfigValue &operator=(const char *s) + { + return *this = std::string(s); + } + +private: + template + typename std::enable_if::value || + std::is_enum::value, + T>::type + value_impl() const + { + return static_cast(to_long_long()); + } + + template + typename std::enable_if::value, T>::type + value_impl() const + { + return static_cast(to_double()); + } + + /** + * @brief Rational and friends: parsed from the stored "num/den" + * string via the type's own static from_string. + */ + template + typename std::enable_if::value && + !std::is_enum::value, + T>::type + value_impl() const + { + return T::from_string(to_string()); + } + + std::string key_; +}; + +class Config { +public: + static Config ¤t() + { + static Config c; + return c; + } + + ConfigValue operator[](const std::string &key) const + { + return ConfigValue(key); + } +}; + +} // namespace olive + +#ifndef OAK_CONFIG +#define OAK_CONFIG(x) Config::current()[x] +#endif +#ifndef OAK_CONFIG_STR +#define OAK_CONFIG_STR(x) Config::current()[x] +#endif + +#endif // OAK_NODE_CONFIGACCESSOR_H diff --git a/src/node/src/node.cpp b/src/node/src/node.cpp index 326117cb9..a5207a6fb 100644 --- a/src/node/src/node.cpp +++ b/src/node/src/node.cpp @@ -31,7 +31,7 @@ #include #include "lerp.h" -#include "config/config.h" +#include "configaccessor.h" #include "group/group.h" #include "project/serializer/typeserializer.h" #include "nodeundo.h" diff --git a/src/node/src/output/viewer/viewer.cpp b/src/node/src/output/viewer/viewer.cpp index 3003fab7a..b6735ce4a 100644 --- a/src/node/src/output/viewer/viewer.cpp +++ b/src/node/src/output/viewer/viewer.cpp @@ -21,7 +21,7 @@ #include "viewer.h" -#include "config/config.h" +#include "configaccessor.h" #include "coreengine.h" #include "traverser.h" #include "olive/core/util/timecodefunctions.h" diff --git a/src/node/src/project/footage/footage.cpp b/src/node/src/project/footage/footage.cpp index f7ce86fad..910351b79 100644 --- a/src/node/src/project/footage/footage.cpp +++ b/src/node/src/project/footage/footage.cpp @@ -32,7 +32,7 @@ #include "filefunctions.h" #include "qtutils.h" #include "xmlutils.h" -#include "config/config.h" +#include "configaccessor.h" #include "olive/core/util/stringutils.h" #include "color/colormanager/colormanager.h" #include "project.h" @@ -392,7 +392,7 @@ void Footage::value(const NodeValueRow &value, const NodeGlobals &globals, // Proxies can be globally disabled (Tools > Use Proxy Media) without // losing each footage's individual proxy setting - // ADAPT(config 波次): engine config/config.h is still Qt-based; kept as-is + // config 波次: resolved via configaccessor.h -> oakcommon_config_* C ABI const bool proxies_allowed = Config::current()[QStringLiteral("UseProxyMedia")].toBool(); diff --git a/src/node/src/project/serializer/serializer210528.cpp b/src/node/src/project/serializer/serializer210528.cpp index 6523ffbf2..0d5919f5b 100644 --- a/src/node/src/project/serializer/serializer210528.cpp +++ b/src/node/src/project/serializer/serializer210528.cpp @@ -24,7 +24,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" #include "factory.h" #include "group/group.h" @@ -836,8 +836,8 @@ void ProjectSerializer210528::load_marker_list(XmlStreamReader *reader, } } - // ADAPT(config/M4): OAK_CONFIG is still Qt-based; TimelineMarker - // is de-Qt'd in M4 (must accept std::string name) + // MarkerColor resolves through configaccessor.h + // (oakcommon_config_* C ABI) new TimelineMarker(OAK_CONFIG("MarkerColor").toInt(), TimeRange(in, out), name, markers); } diff --git a/src/node/src/project/serializer/serializer210907.cpp b/src/node/src/project/serializer/serializer210907.cpp index 90f293721..5ec9da544 100644 --- a/src/node/src/project/serializer/serializer210907.cpp +++ b/src/node/src/project/serializer/serializer210907.cpp @@ -24,7 +24,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" #include "factory.h" #include "group/group.h" @@ -825,8 +825,8 @@ void ProjectSerializer210907::load_marker_list(XmlStreamReader *reader, } } - // ADAPT(config/M4): OAK_CONFIG is still Qt-based; TimelineMarker - // is de-Qt'd in M4 (must accept std::string name) + // MarkerColor resolves through configaccessor.h + // (oakcommon_config_* C ABI) new TimelineMarker(OAK_CONFIG("MarkerColor").toInt(), TimeRange(in, out), name, markers); } diff --git a/src/node/src/project/serializer/serializer211228.cpp b/src/node/src/project/serializer/serializer211228.cpp index 83af856d8..27230175c 100644 --- a/src/node/src/project/serializer/serializer211228.cpp +++ b/src/node/src/project/serializer/serializer211228.cpp @@ -24,7 +24,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" #include "factory.h" #include "group/group.h" @@ -879,8 +879,8 @@ void ProjectSerializer211228::load_marker_list(XmlStreamReader *reader, } } - // ADAPT(config/M4): OAK_CONFIG is still Qt-based; TimelineMarker - // is de-Qt'd in M4 (must accept std::string name) + // MarkerColor resolves through configaccessor.h + // (oakcommon_config_* C ABI) new TimelineMarker(OAK_CONFIG("MarkerColor").toInt(), TimeRange(in, out), name, markers); } diff --git a/src/node/src/project/serializer/serializer220403.cpp b/src/node/src/project/serializer/serializer220403.cpp index 1c505abf2..82bda62b8 100644 --- a/src/node/src/project/serializer/serializer220403.cpp +++ b/src/node/src/project/serializer/serializer220403.cpp @@ -24,7 +24,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" #include "factory.h" #include "block/clip/clip.h" #include "group/group.h" diff --git a/src/node/src/project/serializer/serializer230220.cpp b/src/node/src/project/serializer/serializer230220.cpp index c7a49cf8c..4e05ceab9 100644 --- a/src/node/src/project/serializer/serializer230220.cpp +++ b/src/node/src/project/serializer/serializer230220.cpp @@ -25,7 +25,7 @@ #include "serializer230220.h" -#include "config/config.h" +#include "configaccessor.h" #include "factory.h" #include "group/group.h" #include "serializeddata.h" diff --git a/src/node/transition/config/config.h b/src/node/transition/config/config.h deleted file mode 100644 index 599effc22..000000000 --- a/src/node/transition/config/config.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once -#include -#include "variant.h" -#ifndef QStringLiteral -#define QStringLiteral(x) x -#endif -namespace olive { -class ConfigValue : public olive::Variant { -public: - ConfigValue() = default; - bool toBool() const { return false; } - int toInt() const { return 0; } -}; -class Config { -public: - static Config ¤t() { static Config c; return c; } - ConfigValue operator[](const std::string &) const { return ConfigValue(); } -}; -} -#ifndef OAK_CONFIG -#define OAK_CONFIG(x) Config::current()[x] -#endif -#ifndef OAK_CONFIG_STR -#define OAK_CONFIG_STR(x) Config::current()[x] -#endif diff --git a/src/render/src/configaccessor.h b/src/render/src/configaccessor.h new file mode 100644 index 000000000..e40138b6d --- /dev/null +++ b/src/render/src/configaccessor.h @@ -0,0 +1,168 @@ +/*** + + 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_RENDER_CONFIGACCESSOR_H +#define OAK_RENDER_CONFIGACCESSOR_H + +/** + * @brief Consumer-side shim over the oakcommon_config_* C ABI + * + * config 波次: the old engine/config/config.h (Qt) and the in-memory + * transition/config/config.h stub are gone. This header keeps the + * OAK_CONFIG(...)/Config::current()[...] call shape so call sites did + * not change; every accessor is a C call into liboakcommon. Per the + * cross-module rule only the oakcommon C API is used here, never the + * ConfigStore C++ class. + */ + +#include +#include +#include + +#include "common/config.h" + +#ifndef QStringLiteral +#define QStringLiteral(x) x +#endif + +namespace olive +{ + +/** + * @brief Key-bound value proxy with the QVariant-subset accessors the + * call sites use + */ +class ConfigValue { +public: + ConfigValue() = default; + explicit ConfigValue(const std::string &key) + : key_(key) + { + } + + bool to_bool() const + { + return oakcommon_config_get_bool(nullptr, key_.c_str(), 0) != 0; + } + bool toBool() const { return to_bool(); } + + int to_int() const + { + return oakcommon_config_get_int(nullptr, key_.c_str(), 0); + } + int toInt() const { return to_int(); } + + int64_t to_long_long() const + { + return oakcommon_config_get_int64(nullptr, key_.c_str(), 0); + } + uint64_t to_u_long_long() const + { + return static_cast(to_long_long()); + } + uint64_t toULongLong() const { return to_u_long_long(); } + + double to_double() const + { + return oakcommon_config_get_double(nullptr, key_.c_str(), 0.0); + } + + std::string to_string() const + { + const int size = + oakcommon_config_get(nullptr, key_.c_str(), nullptr, 0); + if (size <= 0) { + return std::string(); + } + std::string s(size - 1, '\0'); + oakcommon_config_get(nullptr, key_.c_str(), s.data(), size); + return s; + } + std::string toString() const { return to_string(); } + + template T value() const { return value_impl(); } + + ConfigValue &operator=(const std::string &s) + { + oakcommon_config_set(nullptr, key_.c_str(), s.c_str()); + return *this; + } + ConfigValue &operator=(const char *s) + { + return *this = std::string(s); + } + +private: + template + typename std::enable_if::value || + std::is_enum::value, + T>::type + value_impl() const + { + return static_cast(to_long_long()); + } + + template + typename std::enable_if::value, T>::type + value_impl() const + { + return static_cast(to_double()); + } + + /** + * @brief Rational and friends: parsed from the stored "num/den" + * string via the type's own static from_string. + */ + template + typename std::enable_if::value && + !std::is_enum::value, + T>::type + value_impl() const + { + return T::from_string(to_string()); + } + + std::string key_; +}; + +class Config { +public: + static Config ¤t() + { + static Config c; + return c; + } + + ConfigValue operator[](const std::string &key) const + { + return ConfigValue(key); + } +}; + +} // namespace olive + +#ifndef OAK_CONFIG +#define OAK_CONFIG(x) Config::current()[x] +#endif +#ifndef OAK_CONFIG_STR +#define OAK_CONFIG_STR(x) Config::current()[x] +#endif + +#endif // OAK_RENDER_CONFIGACCESSOR_H diff --git a/src/render/src/diskmanager.cpp b/src/render/src/diskmanager.cpp index 1e812b0fc..4e07efcc2 100644 --- a/src/render/src/diskmanager.cpp +++ b/src/render/src/diskmanager.cpp @@ -28,7 +28,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" #include "coreengine.h" #include "filefunctions.h" @@ -287,7 +287,7 @@ DiskCacheFolder::DiskCacheFolder(const std::string &path) // reached the folder through queued QMetaObject invocations. int interval = OAK_CONFIG("DiskCacheSaveInterval").toInt(); if (interval <= 0) { - // Config default (10000 ms); the transition config stub returns 0 + // Defensive fallback; the compiled-in config default is 10000 ms interval = 10000; } save_thread_stop_ = false; diff --git a/src/render/src/lutlibrary.cpp b/src/render/src/lutlibrary.cpp index 091ab2fa6..5214d052e 100644 --- a/src/render/src/lutlibrary.cpp +++ b/src/render/src/lutlibrary.cpp @@ -24,7 +24,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" namespace olive { diff --git a/src/render/src/opengl/openglrenderer.cpp b/src/render/src/opengl/openglrenderer.cpp index 7f3e0eeaf..9579c3fb8 100644 --- a/src/render/src/opengl/openglrenderer.cpp +++ b/src/render/src/opengl/openglrenderer.cpp @@ -30,7 +30,7 @@ #include "filefunctions.h" #if !defined(OAK_RENDER_BACKEND_PLUGIN) -#include "config/config.h" +#include "configaccessor.h" #endif #include "render/job/shaderjob.h" diff --git a/src/render/src/previewautocacher.h b/src/render/src/previewautocacher.h index 7c5b2f694..45d238b68 100644 --- a/src/render/src/previewautocacher.h +++ b/src/render/src/previewautocacher.h @@ -29,7 +29,7 @@ #include #include "color/colormanager/colormanager.h" -#include "config/config.h" +#include "configaccessor.h" #include "group/group.h" #include "node.h" #include "output/viewer/viewer.h" diff --git a/src/render/src/rendermanager.cpp b/src/render/src/rendermanager.cpp index 02d5bc1f0..9736d0f42 100644 --- a/src/render/src/rendermanager.cpp +++ b/src/render/src/rendermanager.cpp @@ -26,7 +26,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" #ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND #include "backend/dynamicrenderer.h" #endif diff --git a/src/render/src/rendermanager.h b/src/render/src/rendermanager.h index 4fb5aa445..d16fecef9 100644 --- a/src/render/src/rendermanager.h +++ b/src/render/src/rendermanager.h @@ -29,7 +29,7 @@ #include #include -#include "config/config.h" +#include "configaccessor.h" #include "colorprocessorcache.h" #include "output/viewer/viewer.h" #include "project.h" diff --git a/src/render/transition/config/config.h b/src/render/transition/config/config.h deleted file mode 100644 index 38f654b57..000000000 --- a/src/render/transition/config/config.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once -// Transitional stub for engine/config/config.h (still Qt-based, config 未拆分). -// Union of the src/node/transition stub and the render-facing surface: -// keeps the OAK_CONFIG* call shape; values are inert defaults until the -// config milestone wires the real store. operator[] returns a stored -// reference so writes (lutlibrary) compile; nothing is persisted. 只增不删。 -#include -#include -#include "variant.h" -#ifndef QStringLiteral -#define QStringLiteral(x) x -#endif -namespace olive { -class ConfigValue : public olive::Variant { -public: - ConfigValue() = default; - ConfigValue(const std::string &s) : olive::Variant(s) {} - ConfigValue &operator=(const std::string &s) - { - olive::Variant::operator=(olive::Variant(s)); - return *this; - } - bool toBool() const { return to_bool(); } - int toInt() const { return to_int(); } - uint64_t toULongLong() const { return to_u_long_long(); } - // rendermanager reads the graphics backend name through this - std::string toString() const { return to_string(); } - std::vector toStringList() const { return to_string_list(); } -}; -class Config { -public: - static Config ¤t() { static Config c; return c; } - ConfigValue &operator[](const std::string &key) { return values_[key]; } -private: - std::map values_; -}; -} -#ifndef OAK_CONFIG -#define OAK_CONFIG(x) Config::current()[x] -#endif -#ifndef OAK_CONFIG_STR -#define OAK_CONFIG_STR(x) Config::current()[x] -#endif