refactor: workspace layout — crates/, app at root, legacy C++ removed

Single mechanical restructure commit:
- root Cargo.toml = oakapp bin + workspace; one cargo build produces
  oakapp, oak-cli, oak-worker, liboakengine.dylib
- app/rust/src -> src/ (app at repo root, no rust/ nesting)
- src/<mod>/rust -> crates/oak<mod>; src/oakcore-rs -> crates/oakcore;
  src/bindings/oakotio -> crates/oakotio; src/engine/rust ->
  crates/oakengine (keeps cdylib+staticlib+rlib)
- public C headers include/<mod>/ -> crates/oakengine/include/<mod>/
- OFX SDK headers vendored into crates/oakplugin/ofx/ (HostSupport gone)
- legacy deleted: old src/ C++ modules, engine/, core/, ffmpeg_bridge/,
  app/ (Qt), cli/worker C++, root CMakeLists, third_party/KDDockWidgets
  submodule, otio-install, all build-* output (~40GB)
- oakstorage kept but excluded from the workspace (skeleton w/ todos);
  gpui excluded (own workspace)
- verified: cargo build green, cargo test --workspace 1845/0
  (with the documented OCIO_RS_* env override for the homebrew OCIO)
This commit is contained in:
2026-08-10 20:24:25 +08:00
parent f8540e3892
commit 013a175707
4212 changed files with 8331 additions and 2274987 deletions
+127
View File
@@ -0,0 +1,127 @@
// 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 <http://www.gnu.org/licenses/>.
//! oakcodec C ABI calls (audio encoding + decoding for the manager and
//! waveform extraction) — now direct Rust calls into the oakcodec crate
//! (single-lib unification, see `docs/zh/plans/riir/single-lib.md`).
//!
//! The previous `*mut c_void` handle convention drifted from the real
//! codec C ABI (which uses the shared [`CHandle`]); the wrappers below
//! match the oakcodec ffi signatures exactly.
use std::ffi::{c_char, c_int};
use crate::handle::CHandle;
/// `oakcodec_encoding_params` — single-lib unification: aliases the
/// oakcodec crate's POD (identical layout; only the audio fields are
/// consumed by oakaudio).
pub type EncodingParams = oakcodec::ffi::encoder::oakcodec_encoding_params;
/// `oakcodec_audio_stream_info` — audio stream metadata from probing.
/// Single-lib unification: aliases the oakcodec crate's POD.
pub type AudioStreamInfo = oakcodec::decoder::OakCodecAudioStreamInfo;
/// `oakcodec_encoder_init` — create an encoder for `params`.
pub unsafe fn oakcodec_encoder_init(params: *const EncodingParams) -> CHandle {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_init(params) }
}
/// `oakcodec_encoder_free`.
pub unsafe fn oakcodec_encoder_free(encoder: *mut CHandle) {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_free(encoder) }
}
/// `oakcodec_encoder_open`.
pub unsafe fn oakcodec_encoder_open(encoder: CHandle) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_open(encoder) }
}
/// `oakcodec_encoder_write_audio` — feed interleaved `f32` audio.
pub unsafe fn oakcodec_encoder_write_audio(
encoder: CHandle,
samples: *const f32,
frame_count: c_int,
) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_write_audio(encoder, samples, frame_count) }
}
/// `oakcodec_encoder_flush`.
pub unsafe fn oakcodec_encoder_flush(encoder: CHandle) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_flush(encoder) }
}
/// `oakcodec_encoder_last_error` — copy the last error string into `buf`.
pub unsafe fn oakcodec_encoder_last_error(
encoder: CHandle,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_last_error(encoder, buf, buf_size) }
}
/// `oakcodec_decoder_probe` — create a probe handle for a file.
pub unsafe fn oakcodec_decoder_probe(filename: *const c_char) -> CHandle {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe(filename) }
}
/// `oakcodec_decoder_free`.
pub unsafe fn oakcodec_decoder_free(decoder: *mut CHandle) {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_free(decoder) }
}
/// `oakcodec_decoder_probe_audio_stream_count`.
pub unsafe fn oakcodec_decoder_probe_audio_stream_count(probe: CHandle) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe_audio_stream_count(probe) }
}
/// `oakcodec_decoder_probe_get_audio_stream` — copy audio stream info.
pub unsafe fn oakcodec_decoder_probe_get_audio_stream(
probe: CHandle,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe_get_audio_stream(probe, index, out) }
}
/// `oakcodec_decoder_open` — open stream `stream_index` for decoding.
pub unsafe fn oakcodec_decoder_open(
decoder: CHandle,
filename: *const c_char,
stream_index: c_int,
) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_open(decoder, filename, stream_index) }
}
/// `oakcodec_decoder_decode_audio` — decode/convert frames into `buf`.
#[allow(clippy::too_many_arguments)]
pub unsafe fn oakcodec_decoder_decode_audio(
decoder: CHandle,
in_num: c_int,
in_den: c_int,
out_num: c_int,
out_den: c_int,
sample_rate: c_int,
channel_layout: u64,
buf: *mut f32,
buf_frames: c_int,
) -> c_int {
unsafe {
oakcodec::ffi::decoder::oakcodec_decoder_decode_audio(
decoder, in_num, in_den, out_num, out_den, sample_rate, channel_layout, buf, buf_frames,
)
}
}
+51
View File
@@ -0,0 +1,51 @@
// 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 <http://www.gnu.org/licenses/>.
//! oakcommon C ABI calls (config access + ffmpeg format conversion) —
//! now direct Rust calls into the oakcommon crate (single-lib
//! unification, see `docs/zh/plans/riir/single-lib.md`).
use std::ffi::{c_char, c_int};
/// `oakcommon_config_get` — copy a config string value into `buf`
/// (two-stage; returns the required size including NUL).
pub unsafe fn oakcommon_config_get(
group: *const c_char,
key: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
unsafe { oakcommon::ffi::config::oakcommon_config_get(group, key, buf, buf_size) }
}
/// `oakcommon_config_get_int` — read an integer config value with a
/// default.
pub unsafe fn oakcommon_config_get_int(
group: *const c_char,
key: *const c_char,
default: c_int,
) -> c_int {
unsafe { oakcommon::ffi::config::oakcommon_config_get_int(group, key, default) }
}
/// `oakcommon_ffmpegutils_get_ffmpeg_sample_format` — map an ffmpeg
/// sample format enum to the oak core format, or the reverse.
pub unsafe fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(
smp_fmt: c_int,
out: *mut c_int,
) -> c_int {
unsafe { oakcommon::ffi::ffmpegutils::oakcommon_ffmpegutils_get_ffmpeg_sample_format(smp_fmt, out) }
}
+216
View File
@@ -0,0 +1,216 @@
// 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 <http://www.gnu.org/licenses/>.
//! ffmpeg_bridge C ABI imports (audio filter graph, frames, decoder). The
//! graph converts/resamples/time-stretches planar audio; used by the
//! [`crate::processor`] resampler and the [`crate::waveform`] extractor.
use std::ffi::{c_char, c_int, c_void};
/// `FBSampleFormat` — mirrors `AVSampleFormat` (values cross the C ABI as
/// `int`). `fltp` (planar f32) is the natural exchange format for oakaudio.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:117`.
#[repr(i32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleFormat {
/// No format.
None = -1,
/// Unsigned 8-bit, packed.
U8 = 0,
/// Signed 16-bit, packed.
S16 = 1,
/// Signed 32-bit, packed.
S32 = 2,
/// 32-bit float, packed.
Flt = 3,
/// 64-bit float, packed.
Dbl = 4,
/// Unsigned 8-bit, planar.
U8Planar = 5,
/// Signed 16-bit, planar.
S16Planar = 6,
/// Signed 32-bit, planar.
S32Planar = 7,
/// 32-bit float, planar.
Fltp = 8,
/// 64-bit float, planar.
Dblp = 9,
/// Signed 64-bit, packed.
S64 = 10,
/// Signed 64-bit, planar.
S64Planar = 11,
}
/// `FBAudioGraphConfig` — source graph input/output spec.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:510`.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct AudioGraphConfig {
/// Input sample rate in Hz.
pub in_sample_rate: c_int,
/// Input channel layout mask (`0` = derive from `in_channels`).
pub in_channel_layout_mask: u64,
/// Input sample format (`FBSampleFormat`; planar float in).
pub in_sample_format: c_int,
/// Input channel count.
pub in_channels: c_int,
/// Output sample rate in Hz.
pub out_sample_rate: c_int,
/// Output channel layout mask (`0` = derive from `out_channels`).
pub out_channel_layout_mask: u64,
/// Output sample format (`FBSampleFormat`).
pub out_sample_format: c_int,
/// Output channel count.
pub out_channels: c_int,
/// Whether the output is planar.
pub out_is_planar: c_int,
/// Time-stretch tempo multiplier.
pub tempo: f64,
}
/// Opaque audio filter graph.
pub type AudioGraph = c_void;
/// Opaque frame.
pub type Frame = c_void;
/// Opaque packet.
pub type Packet = c_void;
/// Opaque decoder.
pub type Decoder = c_void;
/// `FBStreamInfo` — decoded stream metadata, mirroring the same-named
/// struct in ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h. Only the
/// audio fields are consumed by oakaudio; the video/container fields are
/// kept to preserve layout.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:335`.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct FBStreamInfo {
/// Stream index.
pub index: c_int,
/// Media type (`FBMediaType`).
pub codec_type: c_int,
/// Opaque FFmpeg codec id.
pub codec_id: c_int,
/// Non-zero if a decoder exists for this stream.
pub has_decoder: c_int,
/// Video width.
pub width: c_int,
/// Video height.
pub height: c_int,
/// Video pixel format (`FBPixelFormat`).
pub pixel_format: c_int,
/// Video field order (`FBFieldOrder`).
pub field_order: c_int,
/// Video color range (`FBColorRange`).
pub color_range: c_int,
/// Raw `AVColorPrimaries` value.
pub color_primaries: c_int,
/// Raw `AVColorTransferCharacteristic` value.
pub color_trc: c_int,
/// Sample rate in Hz.
pub sample_rate: c_int,
/// Sample format (`FBSampleFormat`).
pub sample_format: c_int,
/// Channel layout mask (never zero for valid audio).
pub channel_layout_mask: u64,
/// Stream start time.
pub start_time: i64,
/// Stream duration.
pub duration: i64,
/// Stream time base numerator.
pub time_base_num: c_int,
/// Stream time base denominator.
pub time_base_den: c_int,
/// Average frame rate numerator.
pub avg_frame_rate_num: c_int,
/// Average frame rate denominator.
pub avg_frame_rate_den: c_int,
}
extern "C" {
/// `fb_audio_graph_create` — build a graph from `config`.
pub fn fb_audio_graph_create(config: *const AudioGraphConfig) -> *mut AudioGraph;
/// `fb_audio_graph_free`.
pub fn fb_audio_graph_free(graph: *mut *mut AudioGraph);
/// `fb_audio_graph_push` — push planar samples; `channel_data == NULL`
/// flushes the graph.
pub fn fb_audio_graph_push(
graph: *mut AudioGraph,
channel_data: *const *const u8,
nb_samples: c_int,
) -> c_int;
/// `fb_audio_graph_pull` — pull converted samples. 1 = frame produced,
/// 0 = need more input, negative = error.
pub fn fb_audio_graph_pull(graph: *mut AudioGraph, out_frame: *mut Frame) -> c_int;
/// `fb_channel_layout_get_channels` — channel count of a mask.
pub fn fb_channel_layout_get_channels(mask: u64) -> c_int;
/// `fb_channel_layout_default` — default layout mask for `nb_channels`.
pub fn fb_channel_layout_default(nb_channels: c_int) -> u64;
/// `fb_frame_alloc`.
pub fn fb_frame_alloc() -> *mut Frame;
/// `fb_frame_free`.
pub fn fb_frame_free(frame: *mut *mut Frame);
/// `fb_frame_unref`.
pub fn fb_frame_unref(frame: *mut Frame);
/// `fb_frame_get_nb_samples`.
pub fn fb_frame_get_nb_samples(frame: *const Frame) -> c_int;
/// `fb_frame_set_nb_samples`.
pub fn fb_frame_set_nb_samples(frame: *mut Frame, nb_samples: c_int);
/// `fb_frame_get_sample_rate`.
pub fn fb_frame_get_sample_rate(frame: *const Frame) -> c_int;
/// `fb_frame_get_format`.
pub fn fb_frame_get_format(frame: *const Frame) -> c_int;
/// `fb_frame_get_channel_layout_mask`.
pub fn fb_frame_get_channel_layout_mask(frame: *const Frame) -> u64;
/// `fb_frame_get_data` — writable plane data.
pub fn fb_frame_get_data(frame: *mut Frame, plane: c_int) -> *mut u8;
/// `fb_frame_get_data_const` — read-only plane data.
pub fn fb_frame_get_data_const(frame: *const Frame, plane: c_int) -> *const u8;
/// `fb_frame_get_linesize`.
pub fn fb_frame_get_linesize(frame: *const Frame, plane: c_int) -> c_int;
/// `fb_packet_alloc`.
pub fn fb_packet_alloc() -> *mut Packet;
/// `fb_packet_free`.
pub fn fb_packet_free(packet: *mut *mut Packet);
/// `fb_packet_unref`.
pub fn fb_packet_unref(packet: *mut Packet);
/// `fb_decoder_create`.
pub fn fb_decoder_create() -> *mut Decoder;
/// `fb_decoder_free`.
pub fn fb_decoder_free(decoder: *mut *mut Decoder);
/// `fb_decoder_open` — open stream `stream_index` of `filename`.
pub fn fb_decoder_open(decoder: *mut Decoder, filename: *const c_char, stream_index: c_int)
-> c_int;
/// `fb_decoder_close`.
pub fn fb_decoder_close(decoder: *mut Decoder);
/// `fb_decoder_get_frame` — decode one frame from `packet`.
pub fn fb_decoder_get_frame(decoder: *mut Decoder, packet: *mut Packet, frame: *mut Frame) -> c_int;
/// `fb_decoder_get_packet` — read one packet.
pub fn fb_decoder_get_packet(decoder: *mut Decoder, packet: *mut Packet) -> c_int;
/// `fb_decoder_get_stream_info` — copy stream info into `out`.
pub fn fb_decoder_get_stream_info(decoder: *const Decoder, out: *mut FBStreamInfo) -> c_int;
/// `fb_decoder_get_format_start_time`.
pub fn fb_decoder_get_format_start_time(decoder: *const Decoder) -> i64;
/// `fb_decoder_get_format_duration`.
pub fn fb_decoder_get_format_duration(decoder: *const Decoder) -> i64;
}
+22
View File
@@ -0,0 +1,22 @@
// 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 <http://www.gnu.org/licenses/>.
//! C ABI imports from other oak modules (signatures mirror the public
//! headers verbatim; resolved at link time).
pub mod codec;
pub mod common;
pub mod ffmpeg;
+73
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! The `audio_config` namespace from `src/audio/src/configbridge.*`:
//! audio-specific configuration read through the oakcommon C ABI.
use std::ffi::CString;
/// PortAudio output buffer size in frames; 0 = let PortAudio choose.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:30`
/// (`audio_config::output_buffer_size`).
pub fn output_buffer_size() -> i32 {
unsafe {
crate::bridge::common::oakcommon_config_get_int(
std::ptr::null(),
c"AudioOutputBufferSize".as_ptr(),
0,
)
}
}
/// Name of the configured audio device for `is_output_device`
/// (key "AudioOutput" / "AudioInput"); empty when absent.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:36`
/// (`audio_config::device_name`): two-stage size query; `size <= 1`
/// (absent or empty string) yields the empty string.
pub fn device_name(is_output_device: bool) -> CString {
let key = if is_output_device {
c"AudioOutput"
} else {
c"AudioInput"
};
unsafe {
let size = crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
std::ptr::null_mut(),
0,
);
if size <= 1 {
// Absent (OAKCOMMON_E_NOT_FOUND) or empty
return CString::default();
}
let mut buf = vec![0u8; size as usize];
if crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
buf.as_mut_ptr() as *mut std::ffi::c_char,
size,
) < 0
{
return CString::default();
}
// The buffer is NUL-terminated by the callee.
CString::from_vec_with_nul(buf)
.unwrap_or_else(|e| CString::new(e.into_bytes()).unwrap_or_default())
}
}
+64
View File
@@ -0,0 +1,64 @@
// 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 <http://www.gnu.org/licenses/>.
//! Error codes, mirroring `include/audio/error.h` verbatim; project-wide
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through
//! untranslated. Audio module number is 06, so codes are -60001..-60005.
//! Unlike codec/task there is no CANCELLED code.
/// Success.
pub const OAKAUDIO_OK: i32 = 0;
/// Null handle or invalid argument.
pub const OAKAUDIO_E_INVALID: i32 = -60001;
/// Call not valid in the current state.
pub const OAKAUDIO_E_STATE: i32 = -60002;
/// The underlying operation failed.
pub const OAKAUDIO_E_FAILED: i32 = -60003;
/// Index out of range / entry not found.
pub const OAKAUDIO_E_NOT_FOUND: i32 = -60004;
/// Allocation failed.
pub const OAKAUDIO_E_NOMEM: i32 = -60005;
/// Crate-internal result type; the FFI layer maps it to the codes.
pub type Result<T> = std::result::Result<T, Error>;
/// Crate-internal error.
#[derive(Debug)]
pub enum Error {
/// Null handle or invalid argument.
Invalid,
/// Wrong state.
State,
/// Operation failed (context string is log-only).
Failed(String),
/// Not found.
NotFound,
/// Out of memory.
NoMem,
}
impl Error {
/// Map to the public error code.
pub fn code(&self) -> i32 {
match self {
Error::Invalid => OAKAUDIO_E_INVALID,
Error::State => OAKAUDIO_E_STATE,
Error::Failed(_) => OAKAUDIO_E_FAILED,
Error::NotFound => OAKAUDIO_E_NOT_FOUND,
Error::NoMem => OAKAUDIO_E_NOMEM,
}
}
}
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
// 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 <http://www.gnu.org/licenses/>.
//! Refcounted-handle scaffolding. Same pattern as the oaknode crate
//! (`src/node/rust/src/handle.rs`); intentionally duplicated rather
//! than shared — each module DLL must run its own addref/release code
//! (the function pointers in a handle always point into the DLL that
//! created the object).
//!
//! The `AudioManager` is the one exception: it uses the same `CHandle`
//! layout but with singleton semantics (addref/release no-ops). See
//! `manager.rs` and `include/audio/manager.h`.
//!
//! `// CPP-PARITY: src/audio/c_api/refcounted.h` (RefCounted box,
//! make_handle_in_place, free_handle) and `src/audio/c_api/alive.cpp`
//! (alive ledger behind `oakaudio_debug_alive_count`).
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};
use crate::error::{Error, OAKAUDIO_E_FAILED, OAKAUDIO_OK};
/// ABI version stamped into every handle.
pub const OAKAUDIO_ABI_VERSION: u32 = 1;
/// Live-object ledger behind `oakaudio_debug_alive_count`.
///
/// `// CPP-PARITY: src/audio/c_api/alive.cpp` (`g_alive`).
static ALIVE: AtomicI32 = AtomicI32::new(0);
/// Current number of live refcounted oakaudio objects.
pub fn alive_count() -> i32 {
ALIVE.load(Ordering::Relaxed)
}
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// The shared ABI value-handle type (single-lib unification, see
/// `docs/zh/plans/riir/single-lib.md`): one canonical
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
/// source-compatible. It is `Clone + Copy` (handles cross the C ABI by
/// value) and `Send + Sync` (refcounted, shared across threads).
pub use oakcore_rs::handle::CHandle;
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_addref`).
unsafe extern "C" fn owned_addref<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a `RefBox<T>` created by
// `make_owned`; we only touch it through the reference while it is live.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
b.refs.fetch_add(1, Ordering::Relaxed);
}
}
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_release`):
/// destroys the box at zero and decrements the alive ledger.
unsafe extern "C" fn owned_release<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a live `RefBox<T>` created
// by `make_owned`; the refcount guards against double-free, and the box
// is only reclaimed once the count reaches zero.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
if b.refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(unsafe { Box::from_raw(ctx as *mut RefBox<T>) });
ALIVE.fetch_sub(1, Ordering::Relaxed);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
let b = Box::new(RefBox {
refs: AtomicU32::new(1),
value,
});
ALIVE.fetch_add(1, Ordering::Relaxed);
CHandle {
ctx: Box::into_raw(b) as *mut std::ffi::c_void,
addref: Some(owned_addref::<T>),
release: Some(owned_release::<T>),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// No-op addref/release for singleton (borrowed) handles.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`singleton_addref` /
/// `singleton_release`).
unsafe extern "C" fn noop_ref(_ctx: *mut std::ffi::c_void) {}
/// Borrowed handle for an object owned elsewhere (addref/release are
/// no-ops; nothing is ever freed through the handle).
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle.
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
CHandle {
ctx: ptr as *mut std::ffi::c_void,
addref: Some(noop_ref),
release: Some(noop_ref),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// Typed view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow.
let v = unsafe { &(*(h.ctx as *const RefBox<T>)).value };
Some(v)
}
/// Typed mutable view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get_mut<T: 'static>(h: &CHandle) -> Option<&mut T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow,
// and the caller must not alias it with other live borrows.
let v = unsafe { &mut (*(h.ctx as *mut RefBox<T>)).value };
Some(v)
}
/// Shared free() body: release the ctx, no-op on NULL/empty handle.
///
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`free_handle`).
///
/// # Safety
/// `self_` must be a valid handle pointer or NULL.
pub unsafe fn free_handle(h: *mut CHandle) {
// SAFETY: caller guarantees `h` is a valid handle pointer or NULL.
if let Some(r) = unsafe { h.as_mut() } {
if !r.ctx.is_null() {
if let Some(release) = r.release {
// SAFETY: the release fn belongs to the same DLL and
// accepts the ctx it originally created.
unsafe { release(r.ctx) };
}
r.ctx = std::ptr::null_mut();
}
}
}
/// Panic-catching FFI wrapper for i32-returning exports.
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => OAKAUDIO_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for i32 value-returning exports (errors map
/// to the negative error code).
pub fn guard_int<F: FnOnce() -> crate::error::Result<i32>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
/// Copy a human-readable error string into a C buffer (NUL-terminated,
/// truncated to fit). Returns the required size including the NUL.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`write_error`).
pub fn write_error(s: &str, buf: *mut std::ffi::c_char, buf_size: i32) {
if !buf.is_null() && buf_size > 0 {
let bytes = s.as_bytes();
let n = bytes.len().min((buf_size - 1) as usize);
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, n);
*(buf as *mut u8).add(n) = 0;
}
}
}
/// Convenience: map a condition to an Invalid error.
pub fn invalid_if(cond: bool) -> crate::error::Result<()> {
if cond {
Err(Error::Invalid)
} else {
Ok(())
}
}
+156
View File
@@ -0,0 +1,156 @@
// 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 <http://www.gnu.org/licenses/>.
//! Loudness analysis (`olive::AudioLevelMeter`). A static helper producing
//! per-channel peak/RMS/VU statistics plus an overall LUFS-integrated
//! summary. Pure function module in Rust; feeds the level-meter UI widget.
/// dB floor for all decibel readings.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:30`
/// (`k_decibel_minimum`, inlined from engine/common/decibel.h).
const DECIBEL_MINIMUM: f64 = -200.0;
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:32`
/// (`decibel_from_linear`): -inf clamps to the floor.
fn decibel_from_linear(linear: f64) -> f64 {
let v = 20.0 * linear.log10();
if v.is_infinite() {
return DECIBEL_MINIMUM;
}
v
}
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:99`
/// (`AudioLevelMeter::linear_to_db`).
fn linear_to_db(linear: f64) -> f64 {
if linear <= 0.0 {
return DECIBEL_MINIMUM;
}
decibel_from_linear(linear)
}
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:107`
/// (`AudioLevelMeter::power_to_lufs`): BS.1770-compatible unit, no
/// K-weighting.
fn power_to_lufs(mean_square: f64) -> f64 {
if mean_square <= 0.0 {
return DECIBEL_MINIMUM;
}
-0.691 + 10.0 * mean_square.log10()
}
/// Per-channel statistics for a single analysis pass.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.h`
/// (`AudioLevelMeter::ChannelStats`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChannelStats {
/// Peak amplitude, linear scale. `0.0` when silent.
pub peak_linear: f64,
/// Peak amplitude, decibel scale. `-200.0` when silent.
pub peak_db: f64,
/// Root-mean-square level, linear scale.
pub rms_linear: f64,
/// Root-mean-square level, decibel scale. `-200.0` when silent.
pub rms_db: f64,
/// VU-meter ballistics reading, decibel scale.
pub vu_db: f64,
}
/// Aggregate statistics over all analyzed channels.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.h`
/// (`AudioLevelMeter::Stats`).
#[derive(Debug, Clone, PartialEq)]
pub struct Stats {
/// Per-channel statistics, indexed by channel.
pub channels: Vec<ChannelStats>,
/// Maximum peak across all channels, linear scale.
pub max_peak_linear: f64,
/// Integrated loudness (EBU R128 LUFS). `-200.0` for silence.
pub integrated_lufs: f64,
/// Whether every channel was silent below the noise gate.
pub silence: bool,
}
/// Compute per-channel and summary statistics for a planar sample buffer.
///
/// `planar` holds one f32 slice per channel; every channel slice is assumed to
/// have the same length. Mirrors `AudioLevelMeter::analyze_sample_buffer`.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:42`
/// (`AudioLevelMeter::analyze_sample_buffer`): VU == RMS dB (no separate
/// ballistics); the silence gate is `qFuzzyIsNull` (|x| < 1e-12) on the
/// max peak; LUFS uses the mean square over ALL channels' samples.
pub fn analyze_sample_buffer(planar: &[&[f32]]) -> Stats {
let mut stats = Stats {
channels: vec![
ChannelStats {
peak_linear: 0.0,
peak_db: DECIBEL_MINIMUM,
rms_linear: 0.0,
rms_db: DECIBEL_MINIMUM,
vu_db: DECIBEL_MINIMUM,
};
planar.len()
],
max_peak_linear: 0.0,
integrated_lufs: DECIBEL_MINIMUM,
silence: true,
};
if planar.is_empty() || planar[0].is_empty() {
return stats;
}
let sample_count = planar[0].len();
let mut total_square = 0.0f64;
let mut total_samples = 0usize;
for (channel, data) in planar.iter().enumerate() {
let mut peak = 0.0f64;
let mut square_sum = 0.0f64;
for &s in data.iter() {
let value = f64::from(s);
peak = peak.max(value.abs());
square_sum += value * value;
}
let mean_square = square_sum / sample_count as f64;
let rms = mean_square.sqrt();
let rms_db = linear_to_db(rms);
stats.channels[channel] = ChannelStats {
peak_linear: peak,
peak_db: linear_to_db(peak),
rms_linear: rms,
rms_db,
vu_db: rms_db,
};
stats.max_peak_linear = stats.max_peak_linear.max(peak);
total_square += square_sum;
total_samples += sample_count;
}
// qFuzzyIsNull(double): |x| < 1e-12
stats.silence = stats.max_peak_linear.abs() < 1e-12;
stats.integrated_lufs = power_to_lufs(total_square / total_samples as f64);
stats
}
+45
View File
@@ -0,0 +1,45 @@
// 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 <http://www.gnu.org/licenses/>.
//! # oakaudio — the audio I/O, processing and synchronization engine (Rust)
//!
//! Reimplements the C++ oakaudio module behind its frozen C ABI
//! (`include/audio/*.h`). See README.md for the architectural mapping
//! (singleton manager, stateless sync helpers, local value types).
//!
//! ## FFI discipline
//!
//! Identical to the oaknode crate: every export goes through
//! [`handle::guard*`], handles are opaque refcounted boxes (or, for the
//! singleton `AudioManager`, borrow-only no-ops), shared state behind
//! `Mutex`.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod bridge;
pub mod config;
pub mod error;
pub mod ffi;
pub mod handle;
pub mod levelmeter;
pub mod manager;
pub mod params;
pub mod previewdevice;
pub mod processor;
pub mod synchronizer;
pub mod waveform;
pub mod waveformsync;
+368
View File
@@ -0,0 +1,368 @@
// 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 <http://www.gnu.org/licenses/>.
//! The process-wide PortAudio output/input manager (`olive::AudioManager`).
//!
//! Singleton semantics: the single instance lives behind a
//! `OnceLock<Mutex<ManagerInner>>`; handles returned to C are borrowed and
//! their addref/release are no-ops (mirrors the C++ singleton and
//! `include/audio/manager.h`). An empty handle reports `OAKAUDIO_E_STATE`.
//!
//! Recording goes through the oakcodec encoder C ABI ([`crate::bridge`]);
//! device/config lookups go through oakcommon.
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
use crate::bridge::codec::EncodingParams;
use crate::error::{Error, Result};
use crate::handle::{make_borrowed, CHandle};
use crate::params::AudioParams;
use crate::previewdevice::PreviewAudioDevice;
/// `paNoDevice` (PortAudio "no device" sentinel; also the default when no
/// device is configured).
const PA_NO_DEVICE: i32 = -1;
/// The process-wide manager state. `OnceLock` cannot be reset, so
/// [`destroy_instance`] flips `DESTROYED` to make [`instance`] hand out empty
/// handles again (the singleton box itself is retained).
static MANAGER: OnceLock<Mutex<ManagerInner>> = OnceLock::new();
static DESTROYED: AtomicBool = AtomicBool::new(false);
/// Manager state (all device/stream fields; PortAudio itself is not bridged,
/// see [`ManagerInner::default`] for the degradations).
struct ManagerInner {
/// Current output device index.
output_device: i32,
/// Current input device index.
input_device: i32,
/// Output params the buffer is configured for.
output_params: Option<AudioParams>,
/// Queued output samples feeding the (virtual) playback clock.
output_buffer: PreviewAudioDevice,
/// Whether the output "stream" is running (stand-in for
/// `Pa_IsStreamActive`).
output_started: bool,
/// Active oakcodec recording encoder (NULL when idle).
recording: Option<CHandle>,
}
// SAFETY: the raw encoder pointer is only touched while the manager mutex is
// held, which serializes every access; the encoder lives until `recording` is
// taken out in `stop_recording`.
unsafe impl Send for ManagerInner {}
impl Default for ManagerInner {
fn default() -> Self {
ManagerInner {
// CPP-PARITY: no device is selected until `create_instance` runs
// the config lookup (PortAudio enumeration cannot be bridged, so
// the devices stay at paNoDevice and the C layer reports E_FAILED
// on output/recording until a device is set explicitly).
output_device: PA_NO_DEVICE,
input_device: PA_NO_DEVICE,
output_params: None,
output_buffer: PreviewAudioDevice::new(),
output_started: false,
recording: None,
}
}
}
/// Lock the manager behind a handle; `OAKAUDIO_E_STATE` for empty handles.
fn with_instance(h: &CHandle) -> Result<MutexGuard<'static, ManagerInner>> {
if h.is_null() {
return Err(Error::State);
}
// SAFETY: `instance()` only creates borrowed handles whose ctx points at
// the MANAGER Mutex, which lives in a static for the whole process.
let m: &'static Mutex<ManagerInner> =
unsafe { &*(h.ctx as *const Mutex<ManagerInner>) };
Ok(m.lock().unwrap_or_else(|p| p.into_inner()))
}
/// Create the process-wide AudioManager (no-op when it exists). Returns
/// `OAKAUDIO_OK` or `OAKAUDIO_E_NOMEM`.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:73` (C++ allocates with `new`
/// and reports `OAKAUDIO_E_NOMEM` on exception; Rust allocation infallibly
/// panics, so the error code is never produced).
pub fn create_instance() -> Result<()> {
DESTROYED.store(false, Ordering::SeqCst);
let _ = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
Ok(())
}
/// Destroy the process-wide AudioManager (no-op when absent).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:85` — the C++ singleton is
/// deleted and re-creatable; `OnceLock` cannot be reset, so a `DESTROYED`
/// flag makes [`instance`] return an empty handle (and a later
/// [`create_instance`] resurrects the existing box).
pub fn destroy_instance() {
DESTROYED.store(true, Ordering::SeqCst);
}
/// Return a handle to the process-wide AudioManager (borrowed; empty when
/// no instance exists).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:90` (`wrap`; the handle is a
/// borrowed singleton whose addref/release are no-ops).
pub fn instance() -> CHandle {
if DESTROYED.load(Ordering::SeqCst) {
return CHandle::null();
}
match MANAGER.get() {
Some(m) => {
// SAFETY: `m` is the process-wide singleton; borrowed handles do
// not free it, so it outlives every handle.
unsafe { make_borrowed(m as *const _ as *mut Mutex<ManagerInner>) }
}
None => CHandle::null(),
}
}
/// Release a manager handle. No-op (singleton), safe on NULL/empty.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:95` — releasing never
/// destroys; just clear the caller's copy.
pub fn free(self_: *mut CHandle) {
unsafe {
if let Some(h) = self_.as_mut() {
h.ctx = std::ptr::null_mut();
}
}
}
/// Bytes between output-notify pulses (0 disables).
pub fn set_output_notify_interval(self_: &CHandle, bytes: i64) -> Result<()> {
if bytes < 0 {
return Err(Error::Invalid);
}
let mut m = with_instance(self_)?;
m.output_buffer.set_notify_interval(bytes);
Ok(())
}
/// Push a block of samples to the output device, opening/restarting the
/// stream when the params changed.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:111` — the PortAudio
/// open/start path is not bridged; the buffer is configured and written
/// directly and the "stream" is marked running. `error_buf` is written by
/// the FFI layer from the returned error.
pub fn push_to_output(
self_: &CHandle,
params: AudioParams,
samples: &[u8],
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.output_device == PA_NO_DEVICE {
return Err(Error::Failed("No output device is set".to_string()));
}
if m.output_params.as_ref() != Some(&params) {
m.output_params = Some(params);
m.output_buffer.set_params(params);
}
m.output_buffer.write(samples);
m.output_started = true;
Ok(())
}
/// Discard buffered output.
pub fn clear_buffered_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_buffer.clear();
Ok(())
}
/// Stop the output stream.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:229` (`stop_output` aborts
/// the stream and clears the buffer).
pub fn stop_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Seconds of audio consumed by the output device since the last reset,
/// compensated for output latency; negative when no stream is running.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:169` — PortAudio's
/// `outputLatency` is not representable without a live stream, so the buffer
/// clock is used directly (the `max(0, ...)` clamp is kept).
pub fn seconds(self_: &CHandle, out: &mut f64) -> Result<()> {
let m = with_instance(self_)?;
if !m.output_started {
*out = -1.0;
return Ok(());
}
let rate = m.output_params.map(|p| p.sample_rate).unwrap_or(0);
if rate <= 0 {
*out = -1.0;
return Ok(());
}
let secs = m.output_buffer.output_frames_consumed() as f64 / f64::from(rate);
*out = secs.max(0.0);
Ok(())
}
/// Restart the output clock at zero.
pub fn reset_output_clock(self_: &CHandle) -> Result<()> {
let m = with_instance(self_)?;
m.output_buffer.reset_output_frames();
Ok(())
}
/// Current output device index (`paNoDevice` = -1) or a negative error code.
pub fn get_output_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.output_device)
}
/// Set the output device index.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:238` (the device is
/// recorded and the stream closed; PortAudio's index validation and name
/// logging are not bridged).
pub fn set_output_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_device = device;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Current input device index or a negative error code.
pub fn get_input_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.input_device)
}
/// Set the input device index.
pub fn set_input_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.input_device = device;
Ok(())
}
/// Close the output stream and re-initialize PortAudio.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:271` (PortAudio terminate/
/// initialize is not bridged; the output side is reset).
pub fn hard_reset(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Start recording the input device to a file via the oakcodec encoder C
/// ABI. The input stream is always captured as interleaved f32.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:278` (encoder init/open;
/// the PortAudio input stream is not bridged). On failure the encoder's
/// last-error string is surfaced when available.
pub fn start_recording(
self_: &CHandle,
params: &EncodingParams,
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.input_device == PA_NO_DEVICE {
return Err(Error::Failed("no input device".to_string()));
}
eprintln!("MANAGER before encoder_init: audio_enabled={} codec={}", params.audio_enabled, params.audio_codec);
let mut enc = unsafe { crate::bridge::codec::oakcodec_encoder_init(params) };
eprintln!("MANAGER encoder_init null? {} ptr={:p} size={}", enc.is_null(), params as *const EncodingParams, std::mem::size_of::<EncodingParams>());
let direct = unsafe { oakcodec::ffi::encoder::oakcodec_encoder_init(params as *const EncodingParams) };
eprintln!("MANAGER direct init null? {}", direct.is_null());
if !direct.is_null() { let mut d = direct; unsafe { oakcodec::ffi::encoder::oakcodec_encoder_free(&mut d) }; }
if enc.is_null() {
return Err(Error::Failed(
"failed to open encoder for recording".to_string(),
));
}
let open_r = unsafe { crate::bridge::codec::oakcodec_encoder_open(enc) };
if open_r != 0 {
let mut buf = [0i8; 512];
let n = unsafe {
crate::bridge::codec::oakcodec_encoder_last_error(
enc,
buf.as_mut_ptr(),
buf.len() as i32,
)
};
let msg = if n > 0 {
let s = buf.split(|c| *c == 0).next().unwrap_or(&[]);
let bytes: Vec<u8> = s.iter().map(|&b| b as u8).collect();
String::from_utf8_lossy(&bytes).into_owned()
} else {
"failed to open encoder for recording".to_string()
};
unsafe { crate::bridge::codec::oakcodec_encoder_free(&mut enc) };
return Err(Error::Failed(msg));
}
m.recording = Some(enc);
Ok(())
}
/// Stop recording.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:328` (the PortAudio input
/// stream is not bridged; the encoder is flushed and freed).
pub fn stop_recording(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
if let Some(mut enc) = m.recording.take() {
unsafe {
crate::bridge::codec::oakcodec_encoder_flush(enc);
crate::bridge::codec::oakcodec_encoder_free(&mut enc);
}
}
Ok(())
}
/// Device index named by the configuration ("AudioOutput"/"AudioInput"), or
/// the default when unset/unmatched. Static; `paNoDevice` when PortAudio is
/// not initialized.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:404`.
pub fn find_config_device_by_name_s(is_output_device: bool) -> i32 {
let name = crate::config::device_name(is_output_device);
find_device_by_name_s(&name, is_output_device)
}
/// Device index whose name matches `name` exactly (empty matches nothing,
/// falls through to the default device). Static.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:410` — PortAudio device
/// enumeration cannot be bridged from this crate, so the result is always
/// `paNoDevice` and the caller falls back to the default device.
pub fn find_device_by_name_s(name: &std::ffi::CStr, _is_output_device: bool) -> i32 {
let _ = name;
PA_NO_DEVICE
}
/// Number of live oakaudio reference-counted objects (leak check).
pub fn debug_alive_count() -> i32 {
crate::handle::alive_count()
}
+148
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! Audio value types: `AudioParams` and the `SampleFormat` enum
//! (re-exported from oakcore-rs; planar-first ordering, values identical
//! to `olive::core::SampleFormat::Format` — `// CPP-PARITY:
//! core/include/olive/core/render/sampleformat.h:33`).
//!
//! These integer values cross the C ABI as `int`, so they MUST match the
//! authoritative C++ enums bit-for-bit.
use oakcore_rs::Rational;
/// Audio sample format: re-exported from oakcore-rs (planar-first,
/// values identical to `olive::core::SampleFormat::Format`).
/// `// CPP-PARITY: core/include/olive/core/render/sampleformat.h:33`
pub use oakcore_rs::SampleFormat;
/// Audio stream parameters, mirroring `olive::core::AudioParams`
/// (core/include/olive/core/render/audioparams.h). A plain value type;
/// never bridged through a C ABI handle — liboakcore owns the matching
/// `oakcore_audioparams_*` wrapper and is out of scope here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AudioParams {
/// Sample rate in Hz.
pub sample_rate: i32,
/// ffmpeg-style channel layout mask (0 = unknown/unspecified).
pub channel_layout: u64,
/// Sample format (see [`SampleFormat`]).
pub format: SampleFormat,
}
impl AudioParams {
/// Channel count of the current layout mask (popcount).
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:150`
/// (`AudioParams::calculate_channel_count` —
/// `channel_layout_mask_channel_count`); a layout mask of 0 yields 0,
/// NOT the stereo fallback (the fallback lives in the processor's
/// `fix_channel_layout`).
pub fn channel_count(&self) -> i32 {
self.channel_layout.count_ones() as i32
}
/// Bytes per sample per channel for the format.
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp`
/// (`AudioParams::bytes_per_sample_per_channel`).
pub fn bytes_per_sample_per_channel(&self) -> i64 {
self.format.bytes_per_sample() as i64
}
/// Byte count of `samples` frames across all channels.
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:93`
/// (`AudioParams::samples_to_bytes`).
pub fn samples_to_bytes(&self, samples: i64) -> i64 {
samples * self.bytes_per_sample_per_channel() * i64::from(self.channel_count())
}
}
/// Rebuild a [`SampleFormat`] from the `int` that crossed the C ABI.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:130` — the C++ layers cast
/// the raw `int` straight onto `SampleFormat::Format`, which is UB for
/// out-of-range values but in practice wraps to whatever the enum width
/// holds. The Rust side maps unknown values to `Invalid` (a safe
/// equivalent; no contract test pins the wrapped value).
pub fn sample_format_from_i32(value: i32) -> SampleFormat {
match value {
0 => SampleFormat::U8Planar,
1 => SampleFormat::S16Planar,
2 => SampleFormat::S32Planar,
3 => SampleFormat::S64Planar,
4 => SampleFormat::F32Planar,
5 => SampleFormat::F64Planar,
6 => SampleFormat::U8,
7 => SampleFormat::S16,
8 => SampleFormat::S32,
9 => SampleFormat::S64,
10 => SampleFormat::F32,
11 => SampleFormat::F64,
_ => SampleFormat::Invalid,
}
}
/// Seconds elapsed at `frames` frames given `sample_rate`
/// (`frames/sample_rate` as a rational).
pub fn frames_to_rational(frames: i64, sample_rate: i32) -> Rational {
Rational::new(frames, i64::from(sample_rate))
}
/// Sample index of `time` seconds at `sample_rate` (rounded to nearest,
/// half away from zero).
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:81`
/// (`AudioParams::time_to_samples` uses `std::round`, not truncation).
pub fn rational_to_samples(time: Rational, sample_rate: i32) -> i64 {
(time.to_f64() * f64::from(sample_rate)).round() as i64
}
/// Continued-fraction double → rational conversion.
///
/// `// CPP-PARITY: core/src/util/rational.cpp:39`
/// (`Rational::from_double`): NaN/out-of-range → the null sentinel (0/0);
/// tiny values retried at INT64_MAX precision.
pub fn rational_from_double(flt: f64) -> Rational {
if flt.is_nan() {
return Rational::NULL;
}
if flt.abs() > f64::from(i32::MAX) + 3.0 {
return Rational::NULL;
}
// frexp: flt = f * 2^exp with f in [0.5, 1)
let (mut exponent, _frac) = {
if flt == 0.0 {
(0i32, 0.0)
} else {
let bits = flt.abs().to_bits();
let e = (((bits >> 52) & 0x7ff) as i32) - 1022;
(e, flt)
}
};
exponent = (exponent - 1).max(0);
let den: i64 = 1i64 << (62 - exponent);
let num: i64 = (flt * den as f64 + 0.5).floor() as i64;
let mut r = Rational::new(num, den);
if r.is_null() && flt != 0.0 {
// Too small to represent above; retry with maximum precision.
r = Rational::new((flt * i64::MAX as f64) as i64, i64::MAX);
}
r
}
+195
View File
@@ -0,0 +1,195 @@
// 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 <http://www.gnu.org/licenses/>.
//! Pull-style sample buffer (`olive::PreviewAudioDevice`). The audio backend
//! (PortAudio, in `engine::audio::AudioManager`) pulls samples through
//! [`read`](PreviewAudioDevice::read) from its stream callback; the render
//! side pushes samples through [`write`](PreviewAudioDevice::write). The
//! callback-driven pull semantics are unchanged from the former QIODevice.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Mutex;
use crate::params::AudioParams;
/// A thread-safe ring of raw interleaved sample bytes feeding the audio
/// output callback.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.h`
/// (`PreviewAudioDevice`).
pub struct PreviewAudioDevice {
lock: Mutex<PreviewAudioDeviceInner>,
/// Frames consumed by the output callback (playback clock, includes
/// underrun zero-fill).
output_frames_consumed: AtomicI64,
}
struct PreviewAudioDeviceInner {
/// Queued sample bytes.
buffer: Vec<u8>,
/// Bytes per output frame (0 = unknown until params are set).
bytes_per_frame: i32,
/// Bytes that trigger a notify callback when crossed.
notify_interval: i64,
/// Bytes read so far in the current interval.
bytes_read: i64,
/// Callback fired when a notify interval boundary is crossed.
notify_callback: Option<Box<dyn FnMut() + Send>>,
}
impl PreviewAudioDevice {
/// Create an empty device with unknown frame size.
pub fn new() -> PreviewAudioDevice {
PreviewAudioDevice {
lock: Mutex::new(PreviewAudioDeviceInner {
buffer: Vec::new(),
bytes_per_frame: 0,
notify_interval: 0,
bytes_read: 0,
notify_callback: None,
}),
output_frames_consumed: AtomicI64::new(0),
}
}
/// Read up to `data.len()` bytes from the queued buffer. Called from the
/// audio output callback; returns bytes actually copied (0 on underrun).
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:36`
/// (`PreviewAudioDevice::read`): the notify callback fires AFTER the
/// internal lock is released, and only when an interval boundary is
/// crossed by this read.
pub fn read(&mut self, data: &mut [u8]) -> i64 {
let mut notify = false;
let copy_length;
{
let mut inner = self.lock.lock().unwrap();
copy_length = (data.len() as i64).min(inner.buffer.len() as i64);
if copy_length > 0 {
let new_bytes_read = inner.bytes_read + copy_length;
if inner.notify_interval > 0 && inner.notify_callback.is_some() {
if (inner.bytes_read / inner.notify_interval)
!= (new_bytes_read / inner.notify_interval)
{
notify = true;
}
}
inner.bytes_read = new_bytes_read;
data[..copy_length as usize]
.copy_from_slice(&inner.buffer[..copy_length as usize]);
inner.buffer.drain(..copy_length as usize);
}
}
// Fired outside the lock (see set_notify_callback())
if notify {
let mut cb = self.lock.lock().unwrap().notify_callback.take();
if let Some(c) = cb.as_mut() {
c();
}
let mut inner = self.lock.lock().unwrap();
if inner.notify_callback.is_none() {
inner.notify_callback = cb;
}
}
copy_length
}
/// Append `data` to the queued buffer.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:69`
/// (`PreviewAudioDevice::write`).
pub fn write(&mut self, data: &[u8]) -> i64 {
let mut inner = self.lock.lock().unwrap();
inner.buffer.extend_from_slice(data);
data.len() as i64
}
/// Derive the frame size from the audio format (bytes per sample per
/// channel * channel count).
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:31`
/// (`PreviewAudioDevice::set_params` = `samples_to_bytes(1)`).
pub fn set_params(&mut self, params: AudioParams) {
self.set_bytes_per_frame(params.samples_to_bytes(1) as i32);
}
/// Current bytes per frame (0 = unknown).
pub fn bytes_per_frame(&self) -> i32 {
self.lock.lock().unwrap().bytes_per_frame
}
/// Override the frame size directly.
pub fn set_bytes_per_frame(&mut self, bytes: i32) {
self.lock.lock().unwrap().bytes_per_frame = bytes;
}
/// Set the notify interval in bytes.
pub fn set_notify_interval(&mut self, interval: i64) {
self.lock.lock().unwrap().notify_interval = interval;
}
/// Install the callback fired when a notify interval boundary is crossed.
///
/// Invoked from [`read`](PreviewAudioDevice::read) (the audio output
/// callback thread) after the internal lock is released. Must be
/// thread-safe and must not call back into this device.
pub fn set_notify_callback<F>(&mut self, callback: F)
where
F: FnMut() + Send + 'static,
{
self.lock.lock().unwrap().notify_callback = Some(Box::new(callback));
}
/// Drop all queued bytes and reset the byte counters.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:77`
/// (`PreviewAudioDevice::clear`): also resets the consumed-frames clock.
pub fn clear(&mut self) {
let mut inner = self.lock.lock().unwrap();
inner.buffer.clear();
inner.bytes_read = 0;
self.output_frames_consumed.store(0, Ordering::Relaxed);
}
/// Account for frames consumed by the output callback (including
/// underrun zero-fill).
pub fn add_output_frames(&self, frame_count: i64) {
self.output_frames_consumed
.fetch_add(frame_count, Ordering::Relaxed);
}
/// Frames consumed by the output callback.
pub fn output_frames_consumed(&self) -> i64 {
self.output_frames_consumed.load(Ordering::Relaxed)
}
/// Reset the consumed-frames counter.
pub fn reset_output_frames(&self) {
self.output_frames_consumed.store(0, Ordering::Relaxed);
}
}
impl Default for PreviewAudioDevice {
fn default() -> PreviewAudioDevice {
PreviewAudioDevice::new()
}
}
+331
View File
@@ -0,0 +1,331 @@
// 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 <http://www.gnu.org/licenses/>.
//! The real-time resampler/format converter (`olive::AudioProcessor`).
//!
//! Wraps the ffmpeg_bridge audio filter graph (`fb_audio_graph_*`,
//! `fb_frame_*`) via [`crate::bridge::ffmpeg`]. The conversion output is
//! always planar 32-bit float
//! (`OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == SampleFormat::F32Planar == 4`).
use std::ffi::c_int;
use std::ptr;
use std::sync::Mutex;
use crate::bridge::common::oakcommon_ffmpegutils_get_ffmpeg_sample_format;
use crate::bridge::ffmpeg::{
fb_audio_graph_create, fb_audio_graph_free, fb_audio_graph_pull,
fb_audio_graph_push, fb_channel_layout_default, fb_frame_alloc,
fb_frame_free, fb_frame_get_data, fb_frame_get_nb_samples, AudioGraph,
AudioGraphConfig, Frame,
};
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
use crate::params::{AudioParams, SampleFormat};
/// A closed audio processor (reference count 1; `ctx == NULL` on allocation
/// failure).
pub struct Processor {
inner: Mutex<ProcessorInner>,
}
/// Resampler state behind the handle's mutex.
struct ProcessorInner {
/// Live filter graph (`null` = closed).
graph: *mut AudioGraph,
/// Scratch output frame reused for every pull.
out_frame: *mut Frame,
/// Input spec recorded at `open`.
from: AudioParams,
/// Output spec recorded at `open`.
to: AudioParams,
}
// SAFETY: the raw C pointers are only dereferenced through the ffmpeg_bridge
// ABI while the mutex is held, so all access is serialized; the handle's
// refcount keeps the box alive.
unsafe impl Send for ProcessorInner {}
impl Default for ProcessorInner {
fn default() -> Self {
ProcessorInner {
graph: ptr::null_mut(),
out_frame: ptr::null_mut(),
from: AudioParams {
sample_rate: 0,
channel_layout: 0,
format: SampleFormat::Invalid,
},
to: AudioParams {
sample_rate: 0,
channel_layout: 0,
format: SampleFormat::Invalid,
},
}
}
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:35` — map a native
/// sample format to the bridge format via the oakcommon C ABI (`out` is
/// initialized to `-1` = none; identity in the test stub).
fn to_bridge_sample_format(fmt: SampleFormat) -> c_int {
let mut out: c_int = -1;
unsafe {
oakcommon_ffmpegutils_get_ffmpeg_sample_format(fmt as i32, &mut out);
}
out
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:50` — ensure a usable
/// channel layout mask: 0 (unknown) falls back to a default layout derived
/// from the channel count, itself defaulting to stereo.
fn fix_channel_layout(params: AudioParams) -> AudioParams {
let mut result = params;
if params.channel_layout == 0 {
let mut channels = params.channel_count();
if channels <= 0 {
channels = 2;
}
result.channel_layout = unsafe { fb_channel_layout_default(channels) };
}
result
}
/// Borrow the processor state behind a handle.
fn get_processor(self_: &CHandle) -> Result<&Processor> {
// SAFETY: every non-empty handle returned by `init` boxes a `Processor`.
unsafe { crate::handle::get::<Processor>(self_) }.ok_or(Error::Invalid)
}
/// Create a closed processor.
pub fn init() -> Result<CHandle> {
Ok(make_owned(Processor {
inner: Mutex::new(ProcessorInner::default()),
}))
}
/// Release one reference to a processor (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Open the resampling/format-conversion graph. `out_format` is accepted for
/// interface completeness but the conversion output is always planar f32.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:43` (validation order:
/// empty handle, already-open state, invalid rates/speed, forced output
/// format) and `src/audio/src/audioprocessor.cpp:82` (graph creation).
pub fn open(
self_: &CHandle,
from: AudioParams,
to: AudioParams,
speed: f64,
) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if !inner.graph.is_null() {
// C++: "tried to open a processor that was already open"
return Err(Error::State);
}
if from.sample_rate <= 0 || to.sample_rate <= 0 || speed <= 0.0 {
return Err(Error::Invalid);
}
// The C ABI delivers planar float output only; force the output format
// stage to f32p (OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == 4).
if to.format != SampleFormat::F32Planar {
return Err(Error::Invalid);
}
let from_fixed = fix_channel_layout(from);
let to_fixed = fix_channel_layout(to);
let config = AudioGraphConfig {
in_sample_rate: from_fixed.sample_rate,
in_channel_layout_mask: from_fixed.channel_layout,
in_sample_format: to_bridge_sample_format(from_fixed.format),
in_channels: from_fixed.channel_count(),
out_sample_rate: to_fixed.sample_rate,
out_channel_layout_mask: to_fixed.channel_layout,
out_sample_format: to_bridge_sample_format(to_fixed.format),
out_channels: to_fixed.channel_count(),
out_is_planar: if to_fixed.format.is_planar() { 1 } else { 0 },
tempo: speed,
};
let graph = unsafe { fb_audio_graph_create(&config) };
if graph.is_null() {
// C++: "failed to create audio filter graph"
return Err(Error::Failed("failed to create audio graph".to_string()));
}
inner.graph = graph;
let out_frame = unsafe { fb_frame_alloc() };
if out_frame.is_null() {
// C++: "failed to allocate output frame"; close() unwinds the graph.
unsafe { fb_audio_graph_free(&mut inner.graph) };
return Err(Error::Failed(
"failed to allocate output frame".to_string(),
));
}
inner.out_frame = out_frame;
inner.from = from_fixed;
inner.to = to_fixed;
Ok(())
}
/// Close the graph (safe when closed; handle must be non-empty).
pub fn close(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if !inner.graph.is_null() {
unsafe { fb_audio_graph_free(&mut inner.graph) };
}
if !inner.out_frame.is_null() {
unsafe { fb_frame_free(&mut inner.out_frame) };
}
Ok(())
}
/// 1 when open, 0 when closed; error for an empty handle.
pub fn is_open(self_: &CHandle) -> Result<bool> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
Ok(!inner.graph.is_null())
}
/// Push planar float input and pull converted output. Returns the number of
/// output frames written.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:91` (validation, state
/// check, null `out_planar` short-circuit) and
/// `src/audio/src/audioprocessor.cpp:141` (push/pull loop, byte counting).
pub fn convert(
self_: &CHandle,
in_planar: *const *const f32,
in_frame_count: i32,
out_planar: *const *mut f32,
out_capacity_frames: i32,
) -> Result<i32> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
if inner.graph.is_null() {
return Err(Error::State);
}
if in_frame_count < 0
|| out_capacity_frames < 0
|| (in_frame_count > 0 && in_planar.is_null())
{
return Err(Error::Invalid);
}
let channels = inner.to.channel_count();
if channels <= 0 {
return Err(Error::State);
}
if in_frame_count > 0 {
// The FFI layer has no way to know the input plane count, so the
// plane pointer array is walked using the input spec recorded at
// `open` (`// CPP-PARITY: src/audio/src/audioprocessor.cpp:141`).
let in_channels = inner.from.channel_count();
let mut planes: Vec<*const u8> =
Vec::with_capacity(in_channels.max(0) as usize);
for ch in 0..in_channels {
// SAFETY: `in_planar` is non-null here and the FFI contract
// guarantees at least `from.channel_count()` entries.
let p = unsafe { *in_planar.add(ch as usize) };
planes.push(p as *const u8);
}
let r =
unsafe { fb_audio_graph_push(inner.graph, planes.as_ptr(), in_frame_count) };
if r < 0 {
return Err(Error::Failed(format!(
"failed to add frame to buffersrc: {r}"
)));
}
}
// C++: `out_planar ? &buf : nullptr` — with no destination, the input is
// pushed but nothing is pulled.
if out_planar.is_null() {
return Ok(0);
}
let mut total: i64 = 0;
loop {
let r = unsafe { fb_audio_graph_pull(inner.graph, inner.out_frame) };
if r <= 0 {
if r < 0 {
return Err(Error::Failed(format!(
"failed to pull from buffersink: {r}"
)));
}
break;
}
let nb = unsafe { fb_frame_get_nb_samples(inner.out_frame) };
if nb > 0 && total < i64::from(out_capacity_frames) {
let to_copy =
(i64::from(out_capacity_frames) - total).min(i64::from(nb)) as i32;
for ch in 0..channels {
// SAFETY: the FFI contract guarantees at least `channels`
// entries in `out_planar` (NULL entries are skipped).
let dst = unsafe { *out_planar.add(ch as usize) };
if dst.is_null() {
continue;
}
// Output is planar f32 (enforced by open()); each plane is
// `to_copy` float samples.
let src = unsafe { fb_frame_get_data(inner.out_frame, ch) };
unsafe {
ptr::copy_nonoverlapping(
src as *const u8,
dst as *mut u8,
(to_copy as usize) * 4,
);
}
}
}
total += i64::from(nb);
}
Ok(total.min(i64::from(out_capacity_frames)) as i32)
}
/// Signal end-of-input to the graph (flushes internal delay).
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:137` (empty handle, state)
/// and `src/audio/src/audioprocessor.cpp:210` (flush has no failure path; a
/// negative push return is logged only).
pub fn flush(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
if inner.graph.is_null() {
return Err(Error::State);
}
unsafe {
fb_audio_graph_push(inner.graph, ptr::null(), 0);
}
Ok(())
}
/// Format of the conversion output (always planar f32).
pub const OUTPUT_FORMAT: SampleFormat = SampleFormat::F32Planar;
+98
View File
@@ -0,0 +1,98 @@
// 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 <http://www.gnu.org/licenses/>.
//! Timeline placement helpers (`olive::AudioSynchronizer`). All static in
//! C++; becomes a plain function module. Times are `core::Rational` (from
//! oakcore-rs).
use oakcore_rs::Rational;
use crate::params::rational_from_double;
/// One clip's source-time metadata.
pub struct SourceClip {
/// Source start time in seconds.
pub source_start_time: Rational,
/// Media in point in seconds.
pub media_in: Rational,
/// Whether `source_start_time` is set.
pub has_source_start_time: bool,
}
/// A timeline placement result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Placement {
/// Candidate's timeline in point in seconds.
pub timeline_in: Rational,
/// Whether placement succeeded.
pub valid: bool,
}
/// Place the candidate on the timeline so its source time aligns with the
/// reference clip.
///
/// `// CPP-PARITY: src/audio/src/audiosynchronizer.cpp:27`
/// (`AudioSynchronizer::place_by_source_time`): invalid when either clip
/// lacks a source start time or carries a NaN rational.
pub fn place_by_source_time(
reference: &SourceClip,
candidate: &SourceClip,
reference_timeline_in: Rational,
) -> Placement {
let mut placement = Placement {
timeline_in: Rational::NULL,
valid: false,
};
if !reference.has_source_start_time
|| !candidate.has_source_start_time
|| reference.source_start_time.is_nan()
|| candidate.source_start_time.is_nan()
{
return placement;
}
let reference_head_source = reference.source_start_time + reference.media_in;
let 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.is_nan();
placement
}
/// Timeline placement from a measured waveform offset.
///
/// `// CPP-PARITY: src/audio/src/audiosynchronizer.cpp:50`
/// (`AudioSynchronizer::place_by_waveform_offset`): invalid for
/// `sample_rate <= 0`.
pub fn place_by_waveform_offset(
reference_timeline_in: Rational,
candidate_offset_samples: i64,
sample_rate: i32,
) -> Placement {
let mut placement = Placement {
timeline_in: Rational::NULL,
valid: false,
};
if sample_rate <= 0 {
return placement;
}
placement.timeline_in = reference_timeline_in
+ rational_from_double(candidate_offset_samples as f64 / f64::from(sample_rate));
placement.valid = !placement.timeline_in.is_nan();
placement
}
+891
View File
@@ -0,0 +1,891 @@
// 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 <http://www.gnu.org/licenses/>.
//! Visual waveform store (`olive::AudioVisualWaveform`). Holds channel-
//! interleaved min/max pairs at multiple mipmap levels for efficient display
//! at any zoom scale. `draw_sample()`/`draw_waveform()` are app-layer
//! QPainter helpers and live in the facade; this module only stores and
//! summarizes data.
use std::collections::BTreeMap;
use std::ffi::{c_int, CStr};
use oakcore_rs::Rational;
use crate::bridge::codec::AudioStreamInfo;
use crate::bridge::ffmpeg::{
fb_audio_graph_create, fb_audio_graph_free, fb_audio_graph_pull,
fb_audio_graph_push, fb_decoder_close, fb_decoder_create, fb_decoder_free,
fb_decoder_get_frame, fb_decoder_get_stream_info, fb_decoder_open,
fb_frame_alloc, fb_frame_free, fb_frame_get_data, fb_frame_get_nb_samples,
fb_packet_alloc, fb_packet_free, AudioGraph, AudioGraphConfig, Decoder,
Frame, Packet, SampleFormat,
};
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
/// Maximum channel count accepted by [`extract`]. The C++ plane array is a
/// fixed `OAKAUDIO_EXTRACT_MAX_CHANNELS` (64) stack buffer; the Rust
/// rewrite rejects wider streams instead of overflowing.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:67`.
pub const EXTRACT_MAX_CHANNELS: i32 = 64;
/// Minimum overridable sample rate. Must be a power of two.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:30`
/// (`AudioVisualWaveform::k_minimum_sample_rate`).
pub fn minimum_sample_rate() -> Rational {
Rational::new(1, 8)
}
/// Maximum overridable sample rate. Must be a power of two.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:31`
/// (`AudioVisualWaveform::k_maximum_sample_rate`).
pub fn maximum_sample_rate() -> Rational {
Rational::new(1024, 1)
}
/// One min/max pair for a single channel.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform::SamplePerChannel`).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct SamplePerChannel {
/// Minimum amplitude in the window.
pub min: f32,
/// Maximum amplitude in the window.
pub max: f32,
}
/// One display sample: a `SamplePerChannel` per channel, channel-interleaved.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform::Sample`).
pub type Sample = Vec<SamplePerChannel>;
/// A visual waveform store with mipmapped min/max data.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform`).
#[derive(Debug, Clone)]
pub struct AudioVisualWaveform {
/// Timeline time the stored data starts at (shifts on trim_in).
virtual_start: Rational,
channels: i32,
length: Rational,
// Channel-interleaved min/max samples, keyed by the mipmap sample rate.
mipmapped_data: BTreeMap<Rational, Sample>,
}
/// `floor(time * sample_rate) * channels` — every mipmap index is
/// channel-interleaved, so time conversions scale by the channel count.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:359`
/// (`AudioVisualWaveform::time_to_samples`).
fn time_to_samples(time: f64, sample_rate: f64, channels: i32) -> usize {
let v = (time * sample_rate).floor();
if v <= 0.0 {
return 0;
}
v as usize * channels.max(0) as usize
}
impl AudioVisualWaveform {
/// Create an empty waveform (channel count 0) with the full mipmap
/// chain pre-allocated.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:33`
/// (`AudioVisualWaveform::AudioVisualWaveform`): mipmaps from 1/8 to
/// 1024 points/second, doubling.
pub fn new() -> AudioVisualWaveform {
let mut w = AudioVisualWaveform {
virtual_start: Rational::NULL,
channels: 0,
length: Rational::NULL,
mipmapped_data: BTreeMap::new(),
};
let mut rate = minimum_sample_rate();
while rate <= maximum_sample_rate() {
w.mipmapped_data.insert(rate, Vec::new());
rate = rate * Rational::new(2, 1);
}
w
}
/// Channel count.
pub fn channel_count(&self) -> i32 {
self.channels
}
/// Replace the channel count.
pub fn set_channel_count(&mut self, channels: i32) {
self.channels = channels;
}
/// Length of the waveform in seconds.
pub fn length(&self) -> Rational {
self.length
}
/// Keep `virtual_start` consistent with a new write position.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:90`
/// (`AudioVisualWaveform::validate_virtual_start`): writing before the
/// current start prepends via a NEGATIVE trim_in.
fn validate_virtual_start(&mut self, new_start: Rational) {
if self.length.is_null() {
self.virtual_start = new_start;
} else if self.virtual_start > new_start {
self.trim_in(new_start - self.virtual_start);
}
}
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:40`
/// (`AudioVisualWaveform::overwrite_samples_from_buffer`).
#[allow(clippy::too_many_arguments)]
fn overwrite_samples_from_buffer(
planar: &[&[f32]],
sample_rate: i32,
start: Rational,
target_rate: f64,
channels: i32,
data: &mut Sample,
) -> (usize, usize) {
let sample_count = planar.first().map_or(0, |p| p.len());
let start_index = time_to_samples(start.to_f64(), target_rate, channels);
let samples_length = time_to_samples(
sample_count as f64 / f64::from(sample_rate),
target_rate,
channels,
);
let end_index = start_index + samples_length;
if data.len() < end_index {
data.resize(end_index, SamplePerChannel::default());
}
let chunk_size = f64::from(sample_rate) / target_rate;
let mut i = 0usize;
while i < samples_length {
let src_start =
((i as f64 * chunk_size).round() as usize) / channels as usize;
let src_end = (((i + channels as usize) as f64 * chunk_size).round() as usize
/ channels as usize)
.min(sample_count);
let summary = Self::sum_samples(planar, src_start, src_end - src_start);
data[i + start_index..i + start_index + summary.len()]
.copy_from_slice(&summary);
i += channels as usize;
}
(start_index, samples_length)
}
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:69`
/// (`AudioVisualWaveform::overwrite_samples_from_mipmap`): mipmaps are
/// powers of two so the integer chunk division is exact.
#[allow(clippy::too_many_arguments)]
fn overwrite_samples_from_mipmap(
input: &Sample,
input_sample_rate: f64,
start: Rational,
output_rate: f64,
channels: i32,
output_data: &mut Sample,
input_start: usize,
input_length: usize,
) -> (usize, usize) {
let start_index = time_to_samples(start.to_f64(), output_rate, channels);
let samples_length = time_to_samples(
(input_length / channels as usize) as f64 / input_sample_rate,
output_rate,
channels,
);
let end_index = start_index + samples_length;
if output_data.len() < end_index {
output_data.resize(end_index, SamplePerChannel::default());
}
let chunk_size = (input_sample_rate / output_rate) as usize;
let mut i = 0usize;
while i < samples_length {
let summary = Self::re_sum_samples(
&input[input_start + (i * chunk_size)..],
chunk_size * channels as usize,
channels,
);
output_data[i + start_index..i + start_index + summary.len()]
.copy_from_slice(&summary);
i += channels as usize;
}
(start_index, samples_length)
}
/// Write planar samples into the waveform, expanding as needed.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:98`
/// (`AudioVisualWaveform::overwrite_samples`): the largest mipmap is
/// filled from the raw samples, then each smaller mipmap from the one
/// before it.
pub fn overwrite_samples(&mut self, planar: &[&[f32]], sample_rate: i32, start: Rational) {
if self.channels == 0 {
// C++ logs "channel count is zero" and returns
return;
}
self.validate_virtual_start(start);
// Process the largest mipmap directly from the samples
let rates: Vec<Rational> = self.mipmapped_data.keys().copied().collect();
let channels = self.channels;
let rel_start = start - self.virtual_start;
let mut iter_input: Option<(Sample, f64, usize, usize)> = None;
for rate in rates.iter().rev() {
let out_rate = rate.to_f64();
match iter_input.take() {
None => {
let data = self.mipmapped_data.get_mut(rate).unwrap();
let (s, l) = Self::overwrite_samples_from_buffer(
planar,
sample_rate,
rel_start,
out_rate,
channels,
data,
);
iter_input = Some((data.clone(), out_rate, s, l));
}
Some((input, input_rate, input_start, input_length)) => {
let data = self.mipmapped_data.get_mut(rate).unwrap();
let (s, l) = Self::overwrite_samples_from_mipmap(
&input, input_rate, rel_start, out_rate, channels, data,
input_start, input_length,
);
iter_input = Some((data.clone(), out_rate, s, l));
}
}
}
let sample_count = planar.first().map_or(0, |p| p.len()) as i64;
let sample_length = Rational::new(sample_count, i64::from(sample_rate));
self.length = self.length.max(start + sample_length);
}
/// Copy min/max data from another waveform over a destination range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:137`
/// (`AudioVisualWaveform::overwrite_sums`): source indexing uses the
/// SOURCE's channel count; a null `length` copies everything from
/// `offset`.
pub fn overwrite_sums(
&mut self,
sums: &AudioVisualWaveform,
dest: Rational,
offset: Rational,
length: Rational,
) {
self.validate_virtual_start(dest);
let rates: Vec<Rational> = self.mipmapped_data.keys().copied().collect();
for rate in rates {
let rate_dbl = rate.to_f64();
let their_arr = match sums.mipmapped_data.get(&rate) {
Some(a) => a,
None => continue,
};
// Get our destination sample
let our_start_index =
time_to_samples((dest - self.virtual_start).to_f64(), rate_dbl, self.channels);
// Get our source sample, indexing with the SOURCE's channel count
let their_start_index = (offset.to_f64() * rate_dbl).floor() as usize
* sums.channel_count().max(0) as usize;
if their_start_index >= their_arr.len() {
continue;
}
// Determine how much we're copying
let mut copy_len = their_arr.len() - their_start_index;
if !length.is_null() {
copy_len = copy_len.min(time_to_samples(length.to_f64(), rate_dbl, self.channels));
if copy_len == 0 {
continue;
}
}
let their_slice = their_arr[their_start_index..their_start_index + copy_len].to_vec();
let our_arr = self.mipmapped_data.get_mut(&rate).unwrap();
// Determine end index of our array
let end_index = our_start_index + copy_len;
if our_arr.len() < end_index {
our_arr.resize(end_index, SamplePerChannel::default());
}
our_arr[our_start_index..end_index].copy_from_slice(&their_slice);
}
self.length = self.length.max(
dest + if length.is_null() {
sums.length() - offset
} else {
length
},
);
}
/// Write silence over a range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:187`
/// (`AudioVisualWaveform::overwrite_silence`).
pub fn overwrite_silence(&mut self, start: Rational, length: Rational) {
self.validate_virtual_start(start);
for (rate, our_arr) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let our_start_index = time_to_samples(
(start - self.virtual_start).to_f64(),
rate_dbl,
self.channels,
);
let our_length_index = time_to_samples(length.to_f64(), rate_dbl, self.channels);
let our_end_index = our_start_index + our_length_index;
if our_arr.len() < our_end_index {
our_arr.resize(our_end_index, SamplePerChannel::default());
}
for p in &mut our_arr[our_start_index..our_start_index + our_length_index] {
*p = SamplePerChannel::default();
}
}
self.length = self.length.max(start + length);
}
/// Trim the start of the waveform.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:218`
/// (`AudioVisualWaveform::trim_in`): a NEGATIVE length prepends silence
/// and leaves `length_` unchanged (the absolute end does not move).
pub fn trim_in(&mut self, length: Rational) {
if length.is_null() {
return;
}
self.virtual_start = self.virtual_start + length;
let negative = length < Rational::NULL || length.to_f64() < 0.0;
let abs_length = if negative { Rational::NULL - length } else { length };
for (rate, data) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let chop_length = time_to_samples(abs_length.to_f64(), rate_dbl, self.channels);
if chop_length == 0 {
continue;
}
if !negative {
let drop = chop_length.min(data.len());
data.drain(..drop);
} else {
let mut padded = vec![SamplePerChannel::default(); chop_length];
padded.extend_from_slice(data);
*data = padded;
}
}
if !negative {
self.length = Rational::new(0, 1).max(self.length - abs_length);
}
// Prepending grows the data before the existing start, so the absolute
// end (which length_ tracks) is unchanged
}
/// Take a sub-waveform starting at `offset`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:252`
/// (`AudioVisualWaveform::mid`).
pub fn mid(&self, offset: Rational, length: Rational) -> AudioVisualWaveform {
let mut mid = self.clone();
mid.trim_range(offset - self.virtual_start, length);
mid
}
/// Resize to `length`, truncating or padding with silence.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:268`
/// (`AudioVisualWaveform::resize`).
pub fn resize(&mut self, length: Rational) {
if self.length == length {
return;
}
for (rate, data) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let chop_length = time_to_samples(length.to_f64(), rate_dbl, self.channels);
data.resize(chop_length, SamplePerChannel::default());
}
self.length = length;
}
/// Trim to a range starting at `in` with the given `length`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:286`
/// (`AudioVisualWaveform::trim_range`).
pub fn trim_range(&mut self, r#in: Rational, length: Rational) {
self.trim_in(r#in);
self.resize(length);
}
/// Pick the smallest mipmap whose rate covers `scale`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:365`
/// (`AudioVisualWaveform::get_mipmap_for_scale`): falls back to the
/// largest mipmap when none is sufficient.
fn get_mipmap_for_scale(&self, scale: f64) -> (&Rational, &Sample) {
for (rate, data) in self.mipmapped_data.iter() {
if rate.to_f64() >= scale {
return (rate, data);
}
}
self.mipmapped_data.iter().next_back().unwrap()
}
/// Return summarized min/max pairs for a time range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:292`
/// (`AudioVisualWaveform::get_summary_from_time`): a start past the end
/// of the data returns zero pairs instead of underflowing (signed
/// `available` comparison).
pub fn get_summary_from_time(&self, start: Rational, length: Rational) -> Sample {
// Find mipmap that requires
let (rate, mipmap_data) = self.get_mipmap_for_scale(length.to_f64().recip_or_zero());
let rate_dbl = rate.to_f64();
let start_sample =
time_to_samples((start - self.virtual_start).to_f64(), rate_dbl, self.channels);
let mut sample_length = time_to_samples(length.to_f64(), rate_dbl, self.channels);
// Determine if the array actually has this sample. Compare in signed
// arithmetic so a start past the end of the data doesn't underflow.
let available = mipmap_data.len() as i64 - start_sample as i64;
if available > 0 {
sample_length = sample_length.min(available as usize);
if sample_length > 0 {
return Self::re_sum_samples(
&mipmap_data[start_sample..],
sample_length,
self.channels,
);
}
}
// Return null samples
vec![
SamplePerChannel::default();
self.channel_count().max(0) as usize
]
}
/// Reduce planar samples into min/max pairs.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:329`
/// (`AudioVisualWaveform::sum_samples`; scalar fallback of
/// `expand_min_max_channel`, the SIMD path is numerically identical).
pub fn sum_samples(planar: &[&[f32]], start_index: usize, length: usize) -> Sample {
let mut summed = Vec::with_capacity(planar.len());
for data in planar {
let end = (start_index + length).min(data.len());
let mut min_val: f32;
let mut max_val: f32;
if start_index < end {
min_val = data[start_index];
max_val = data[start_index];
for &s in &data[start_index + 1..end] {
if s < min_val {
min_val = s;
}
if s > max_val {
max_val = s;
}
}
} else {
min_val = 0.0;
max_val = 0.0;
}
summed.push(SamplePerChannel {
min: min_val,
max: max_val,
});
}
summed
}
/// Merge already-summed min/max pairs across channels.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:353`
/// (`AudioVisualWaveform::re_sum_samples`): initialized from the FIRST
/// point rather than {0,0} — the engine version clamped all-positive
/// (resp. all-negative) ranges to zero; fixed in oakaudio (see the
/// comment in the C++ source).
pub fn re_sum_samples(samples: &[SamplePerChannel], nb_samples: usize, nb_channels: i32) -> Sample {
let channel_count = nb_channels.max(0) as usize;
let mut summed = vec![SamplePerChannel::default(); channel_count];
let nb_samples = nb_samples.min(samples.len());
// Initialize from the first point instead of {0,0}
if nb_samples >= channel_count {
summed[..channel_count].copy_from_slice(&samples[..channel_count]);
}
let mut i = 0usize;
while i < nb_samples {
for j in 0..channel_count {
if i + j >= samples.len() {
break;
}
let sample = samples[i + j];
if sample.min < summed[j].min {
summed[j].min = sample.min;
}
if sample.max > summed[j].max {
summed[j].max = sample.max;
}
}
i += nb_channels.max(1) as usize;
}
summed
}
}
impl Default for AudioVisualWaveform {
fn default() -> AudioVisualWaveform {
AudioVisualWaveform::new()
}
}
/// f64 reciprocal that yields 0 for a zero denominator (0/0 or 0-length
/// rationals): C++ `length.flipped().to_double()` on a null rational is
/// NaN; NaN never satisfies `rate >= scale` so the largest mipmap is
/// picked. We mirror that by returning NaN for the null case.
trait RecipOrZero {
fn recip_or_zero(self) -> f64;
}
impl RecipOrZero for f64 {
fn recip_or_zero(self) -> f64 {
if self == 0.0 || self.is_nan() {
f64::NAN
} else {
1.0 / self
}
}
}
// ---- Handle plumbing (mirrors processor.rs) --------------------------------
/// Create an empty waveform behind a refcounted handle (count 1).
pub fn init() -> Result<CHandle> {
Ok(make_owned(AudioVisualWaveform::new()))
}
/// Release one reference to a waveform (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Borrow the waveform behind a handle; `OAKAUDIO_E_INVALID` for empty.
pub fn get(self_: &CHandle) -> Result<&AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
/// Mutable variant of [`get`].
pub fn get_mut(self_: &CHandle) -> Result<&mut AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get_mut::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
// ---- Whole-file extraction --------------------------------------------------
/// Result of [`extract`]: channel-interleaved min/max pairs.
pub struct ExtractOutcome {
/// `points * channels` channel-interleaved min/max pairs.
pub points: Vec<SamplePerChannel>,
/// Channel count of the decoded stream.
pub channels: i32,
}
/// Append a pulled frame's per-channel planar f32 samples to `pending`.
fn append_pending(pending: &mut Vec<Vec<f32>>, frame: *mut Frame, channels: i32, nb: i32) {
// SAFETY: `frame` is a live graph-output frame (`fltp`), `channels` was
// validated against the stream info and `nb` comes from the same frame.
if pending.is_empty() {
pending.resize(channels.max(0) as usize, Vec::new());
}
for ch in 0..channels {
let data = unsafe { fb_frame_get_data(frame, ch) } as *const f32;
let slice = unsafe { std::slice::from_raw_parts(data, nb as usize) };
pending[ch as usize].extend_from_slice(slice);
}
}
/// Emit one channel-interleaved point per `samples_per_point` pending
/// samples; with `flush`, a trailing partial point is emitted too.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:88` (`emit_points`).
fn emit_points(
pending: &mut Vec<Vec<f32>>,
channels: i32,
samples_per_point: i32,
points: &mut Vec<SamplePerChannel>,
flush: bool,
) {
if pending.is_empty() {
return;
}
loop {
let available = pending[0].len();
if available == 0 || (!flush && available < samples_per_point as usize) {
return;
}
let n = available.min(samples_per_point as usize);
let point = points.len() / channels as usize;
points.resize(points.len() + channels as usize, SamplePerChannel::default());
for ch in 0..channels {
let plane = &mut pending[ch as usize];
let mut mn = plane[0];
let mut mx = mn;
for &v in &plane[1..n] {
mn = mn.min(v);
mx = mx.max(v);
}
points[point * channels as usize + ch as usize] = SamplePerChannel { min: mn, max: mx };
plane.drain(..n);
}
}
}
/// Decode a whole audio stream to a channel-interleaved min/max summary.
///
/// The stream is probed through the oakcodec decoder C ABI and decoded via
/// ffmpeg_bridge (`fb_decoder` + `fb_audio_graph`), then reduced to one
/// point per `samples_per_point` source samples.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:404`
/// (`oakaudio_waveform_extract`).
pub fn extract(filename: &CStr, stream_index: i32, samples_per_point: i32) -> Result<ExtractOutcome> {
// Probe for the stream's native rate/layout (stateless).
// SAFETY: `filename` is a NUL-terminated C string (validated by the FFI
// layer); the probe handle is freed on every path below.
let mut probe = unsafe { crate::bridge::codec::oakcodec_decoder_probe(filename.as_ptr()) };
if probe.is_null() {
return Err(Error::NotFound);
}
let mut info = unsafe { std::mem::zeroed::<AudioStreamInfo>() };
let r = unsafe {
crate::bridge::codec::oakcodec_decoder_probe_get_audio_stream(
probe,
stream_index,
&mut info,
)
};
// SAFETY: `probe` was created above and is no longer used.
unsafe { crate::bridge::codec::oakcodec_decoder_free(&mut probe) };
if r != 0 {
return Err(Error::NotFound);
}
if info.sample_rate <= 0 || info.channel_count <= 0 {
return Err(Error::Failed("invalid audio stream".to_string()));
}
let channels = info.channel_count;
if channels > EXTRACT_MAX_CHANNELS {
return Err(Error::Failed(format!(
"stream has {channels} channels (max {EXTRACT_MAX_CHANNELS})"
)));
}
// Decode the whole stream through ffmpeg_bridge.
let decoder = unsafe { fb_decoder_create() };
if decoder.is_null() {
return Err(Error::NoMem);
}
// SAFETY: `decoder` is live until `fb_decoder_free` below; every early
// return releases it first.
let open_r = unsafe { fb_decoder_open(decoder, filename.as_ptr(), info.stream_index) };
if open_r < 0 {
// SAFETY: `decoder` is a live ffmpeg_bridge decoder.
unsafe { fb_decoder_free(&mut (decoder as *mut Decoder)) };
return Err(Error::Failed(format!("failed to open decoder: {open_r}")));
}
let mut sinfo = unsafe { std::mem::zeroed::<crate::bridge::ffmpeg::FBStreamInfo>() };
if unsafe { fb_decoder_get_stream_info(decoder, &mut sinfo) } < 0 || sinfo.sample_rate <= 0 {
// SAFETY: see above.
unsafe {
fb_decoder_close(decoder);
fb_decoder_free(&mut (decoder as *mut Decoder));
}
return Err(Error::Failed(
"failed to query decoder stream info".to_string(),
));
}
let config = AudioGraphConfig {
in_sample_rate: sinfo.sample_rate,
in_channel_layout_mask: sinfo.channel_layout_mask,
in_sample_format: sinfo.sample_format,
in_channels: channels,
out_sample_rate: sinfo.sample_rate,
out_channel_layout_mask: sinfo.channel_layout_mask,
out_sample_format: SampleFormat::Fltp as c_int,
out_channels: channels,
out_is_planar: 1,
tempo: 1.0,
};
let mut packet = unsafe { fb_packet_alloc() };
let mut frame = unsafe { fb_frame_alloc() };
let mut converted = unsafe { fb_frame_alloc() };
let graph = unsafe { fb_audio_graph_create(&config) };
if packet.is_null() || frame.is_null() || converted.is_null() {
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
return Err(Error::NoMem);
}
if graph.is_null() {
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
return Err(Error::Failed(
"failed to create audio filter graph".to_string(),
));
}
let mut pending: Vec<Vec<f32>> = Vec::new();
let mut points: Vec<SamplePerChannel> = Vec::new();
// SAFETY: all handles are live; `frame` holds the decoded frame and
// `converted` the graph output.
let mut result: Result<()> = Ok(());
'decode: loop {
let r = unsafe { fb_decoder_get_frame(decoder, packet, frame) };
if r < 0 {
break; // EOF or error: stop decoding (C++ breaks on < 0)
}
// Push the decoded frame (planar pointer array; a packed source is
// read from plane 0 by the buffersrc).
let nb = unsafe { fb_frame_get_nb_samples(frame) };
let mut planes: Vec<*const u8> = Vec::with_capacity(channels as usize);
for ch in 0..channels {
// SAFETY: `frame` carries at least `channels` planes for the
// decoded format (validated stream info).
planes.push(unsafe { fb_frame_get_data(frame, ch) });
}
if unsafe { fb_audio_graph_push(graph, planes.as_ptr(), nb) } < 0 {
result = Err(Error::Failed("failed to push decoded frame".to_string()));
break 'decode;
}
// Drain the graph: pull converted output until no more is available.
loop {
let pull = unsafe { fb_audio_graph_pull(graph, converted) };
if pull < 0 {
result = Err(Error::Failed("failed to pull from graph".to_string()));
break 'decode;
}
if pull == 0 {
break;
}
let nb = unsafe { fb_frame_get_nb_samples(converted) };
append_pending(&mut pending, converted, channels, nb);
emit_points(&mut pending, channels, samples_per_point, &mut points, false);
}
}
// Flush the resampler delay (identity in the extract path, so this only
// emits the trailing partial window).
if result.is_ok() {
// SAFETY: `graph` is live; NULL channel data signals EOF.
unsafe { fb_audio_graph_push(graph, std::ptr::null(), 0) };
loop {
let pull = unsafe { fb_audio_graph_pull(graph, converted) };
if pull <= 0 {
break;
}
let nb = unsafe { fb_frame_get_nb_samples(converted) };
append_pending(&mut pending, converted, channels, nb);
}
emit_points(&mut pending, channels, samples_per_point, &mut points, true);
}
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
result?;
Ok(ExtractOutcome { points, channels })
}
/// Free every resource allocated by [`extract`] after the graph/decoder
/// creation succeeded.
fn cleanup_extract(
graph: *mut AudioGraph,
converted: &mut *mut Frame,
frame: &mut *mut Frame,
packet: &mut *mut Packet,
decoder: *mut Decoder,
) {
// SAFETY: the pointers were produced by the corresponding ffmpeg_bridge
// allocators and are freed exactly once here.
unsafe {
if !graph.is_null() {
fb_audio_graph_free(&mut (graph as *mut AudioGraph));
}
if !converted.is_null() {
fb_frame_free(converted);
}
if !frame.is_null() {
fb_frame_free(frame);
}
if !packet.is_null() {
fb_packet_free(packet);
}
fb_decoder_close(decoder);
fb_decoder_free(&mut (decoder as *mut Decoder));
}
}
+338
View File
@@ -0,0 +1,338 @@
// 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 <http://www.gnu.org/licenses/>.
//! Waveform-based audio synchronization (`olive::AudioWaveformSync`). Pure
//! static helpers that correlate RMS envelopes to estimate sample offsets and
//! playback-rate corrections. No shared state.
/// A candidate offset and its correlation confidence.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.h`
/// (`AudioWaveformSync::OffsetResult`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OffsetResult {
/// Offset of the candidate relative to the reference, in samples.
pub offset_samples: i64,
/// Normalized correlation confidence in `[0, 1]`.
pub confidence: f64,
/// Whether an offset could be determined.
pub valid: bool,
}
/// A playback-rate change plus offset aligning candidate to reference.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.h`
/// (`AudioWaveformSync::StretchOffsetResult`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StretchOffsetResult {
/// Rate the candidate must play at to align (`2.0` = candidate runs at
/// half speed and must be sped up 2x).
pub rate: f64,
/// Offset in samples.
pub offset_samples: i64,
/// Normalized correlation confidence in `[0, 1]`.
pub confidence: f64,
/// Whether a rate+offset could be determined.
pub valid: bool,
}
/// Extract a windowed RMS envelope from a planar sample buffer.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:28`
/// (`AudioWaveformSync::extract_rms_envelope`): the trailing partial window
/// is kept; the mean is over ALL channels' samples in the window.
pub fn extract_rms_envelope(planar: &[&[f32]], window_samples: usize) -> Vec<f64> {
let mut envelope = Vec::new();
let channel_count = planar.len();
let sample_count = if channel_count > 0 { planar[0].len() } else { 0 };
if channel_count == 0 || sample_count == 0 || window_samples == 0 {
return envelope;
}
let window_count = sample_count.div_ceil(window_samples);
envelope.resize(window_count, 0.0);
for window in 0..window_count {
let start = window * window_samples;
let end = (start + window_samples).min(sample_count);
let mut square_sum = 0.0f64;
let mut total = 0usize;
for data in planar.iter() {
for &s in &data[start..end] {
let value = f64::from(s);
square_sum += value * value;
total += 1;
}
}
envelope[window] = if total > 0 {
(square_sum / total as f64).sqrt()
} else {
0.0
};
}
envelope
}
/// Estimate a plain sample offset between two planar buffers.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:65`
/// (`AudioWaveformSync::estimate_offset`).
pub fn estimate_offset(
reference: &[&[f32]],
candidate: &[&[f32]],
window_samples: usize,
max_offset_samples: i64,
) -> OffsetResult {
if window_samples == 0 {
return OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: false,
};
}
let reference_envelope = extract_rms_envelope(reference, window_samples);
let candidate_envelope = extract_rms_envelope(candidate, window_samples);
let max_offset_windows = max_offset_samples / window_samples as i64;
estimate_envelope_offset(
&reference_envelope,
&candidate_envelope,
window_samples,
max_offset_windows,
)
}
/// Estimate an offset from RMS envelopes, treating both as fully valid.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:84`
/// (`AudioWaveformSync::estimate_envelope_offset`, unmasked overload).
pub fn estimate_envelope_offset(
reference: &[f64],
candidate: &[f64],
window_samples: usize,
max_offset_windows: i64,
) -> OffsetResult {
estimate_envelope_offset_valid(
reference,
candidate,
&[],
&[],
window_samples,
max_offset_windows,
)
}
/// Estimate an offset from RMS envelopes, excluding windows flagged invalid.
///
/// Empty masks are treated as "all windows valid". This is the variant the
/// frozen C ABI exposes.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:95`
/// (`AudioWaveformSync::estimate_envelope_offset`, masked overload): a mask
/// whose size does NOT match its envelope is ignored entirely
/// (`mask.size() != size || mask.at(index)` — load-bearing); lags with
/// fewer than 2 valid overlap windows are skipped; windows whose
/// correlation energy is zero (`qFuzzyIsNull`, < 1e-12) are skipped;
/// confidence is `max(0, best_score)`.
pub fn estimate_envelope_offset_valid(
reference: &[f64],
candidate: &[f64],
reference_valid: &[bool],
candidate_valid: &[bool],
window_samples: usize,
max_offset_windows: i64,
) -> OffsetResult {
let mut result = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: false,
};
if reference.is_empty() || candidate.is_empty() || window_samples == 0 {
return result;
}
let is_valid = |mask: &[bool], size: usize, index: usize| -> bool {
mask.len() != size || mask[index]
};
let mut best_score = -2.0f64;
let mut best_lag = 0i64;
let reference_size = reference.len() as i64;
let candidate_size = candidate.len() as i64;
for lag in -max_offset_windows..=max_offset_windows {
let reference_start = 0i64.max(-lag);
let candidate_start = 0i64.max(lag);
let overlap = (reference_size - reference_start).min(candidate_size - candidate_start);
if overlap < 2 {
continue;
}
// Only windows marked valid on both sides participate in the score
let mut reference_mean = 0.0f64;
let mut candidate_mean = 0.0f64;
let mut valid_count = 0i64;
for i in 0..overlap {
let reference_index = (reference_start + i) as usize;
let candidate_index = (candidate_start + i) as usize;
if !is_valid(reference_valid, reference.len(), reference_index)
|| !is_valid(candidate_valid, candidate.len(), candidate_index)
{
continue;
}
reference_mean += reference[reference_index];
candidate_mean += candidate[candidate_index];
valid_count += 1;
}
if valid_count < 2 {
continue;
}
reference_mean /= valid_count as f64;
candidate_mean /= valid_count as f64;
let mut numerator = 0.0f64;
let mut reference_energy = 0.0f64;
let mut candidate_energy = 0.0f64;
for i in 0..overlap {
let reference_index = (reference_start + i) as usize;
let candidate_index = (candidate_start + i) as usize;
if !is_valid(reference_valid, reference.len(), reference_index)
|| !is_valid(candidate_valid, candidate.len(), candidate_index)
{
continue;
}
let reference_value = reference[reference_index] - reference_mean;
let candidate_value = candidate[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 reference_energy.abs() < 1e-12 || candidate_energy.abs() < 1e-12 {
continue;
}
let score = numerator / (reference_energy * candidate_energy).sqrt();
if score > best_score {
best_score = score;
best_lag = lag;
}
}
if best_score > -2.0 {
result.valid = true;
result.confidence = best_score.max(0.0);
result.offset_samples = best_lag * window_samples as i64;
}
result
}
/// Estimate a playback-rate change plus offset aligning the candidate to the
/// reference, resampling the candidate at each rate in `[min_rate, max_rate]`.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:202`
/// (`AudioWaveformSync::estimate_stretch_and_offset`): the rate loop upper
/// bound is `max_rate + rate_step * 0.5` (a half-step tolerance, so
/// floating-point step accumulation still reaches max_rate); a resampled
/// window is valid only when BOTH source windows are valid; a wrong-sized
/// candidate mask means all-valid.
pub fn estimate_stretch_and_offset(
reference: &[f64],
candidate: &[f64],
reference_valid: &[bool],
candidate_valid: &[bool],
window_samples: usize,
max_offset_windows: i64,
min_rate: f64,
max_rate: f64,
rate_step: f64,
) -> StretchOffsetResult {
let mut result = StretchOffsetResult {
rate: 1.0,
offset_samples: 0,
confidence: 0.0,
valid: false,
};
if reference.is_empty()
|| candidate.is_empty()
|| window_samples == 0
|| min_rate <= 0.0
|| max_rate < min_rate
|| rate_step <= 0.0
{
return result;
}
let mut best_confidence = -2.0f64;
let mut rate = min_rate;
while rate <= max_rate + rate_step * 0.5 {
// Resample the candidate envelope so that window i of the resampled
// envelope corresponds to window i*rate of the original
let resampled_size = (candidate.len() as f64 / rate) as i64;
if resampled_size < 2 {
rate += rate_step;
continue;
}
let resampled_len = resampled_size as usize;
let mut resampled = vec![0.0f64; resampled_len];
let mut resampled_valid = vec![false; resampled_len];
for i in 0..resampled_size as usize {
let position = i as f64 * rate;
let lower = position as usize;
let upper = (lower + 1).min(candidate.len() - 1);
let fraction = position - lower as f64;
resampled[i] = candidate[lower] * (1.0 - fraction) + candidate[upper] * fraction;
resampled_valid[i] = candidate_valid.len() != candidate.len()
|| (candidate_valid[lower] && candidate_valid[upper]);
}
let offset = estimate_envelope_offset_valid(
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;
}
rate += rate_step;
}
result
}