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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(¶ms) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user