diff --git a/Cargo.lock b/Cargo.lock
index 64ba9f882..9515aa7df 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3924,6 +3924,7 @@ dependencies = [
name = "oakaudio"
version = "0.1.0"
dependencies = [
+ "ffmpeg-next",
"oakcodec",
"oakcommon",
"oakcore-rs",
diff --git a/crates/oakaudio/Cargo.toml b/crates/oakaudio/Cargo.toml
index a2f85099b..53ea850d9 100644
--- a/crates/oakaudio/Cargo.toml
+++ b/crates/oakaudio/Cargo.toml
@@ -12,3 +12,7 @@ crate-type = ["staticlib", "rlib"]
oakcore-rs = { path = "../oakcore" }
oakcommon = { path = "../oakcommon" }
oakcodec = { path = "../oakcodec" }
+# Real resample/channel-convert/time-stretch filter graph. The C++
+# ffmpeg_bridge library existed only to absorb FFmpeg API churn; the Rust
+# crate calls ffmpeg-next directly (same as oakcodec).
+ffmpeg-next = "9"
diff --git a/crates/oakaudio/src/bridge/ffmpeg.rs b/crates/oakaudio/src/bridge/ffmpeg.rs
deleted file mode 100644
index 6f19e565b..000000000
--- a/crates/oakaudio/src/bridge/ffmpeg.rs
+++ /dev/null
@@ -1,216 +0,0 @@
-// 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 .
-
-//! 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;
-}
diff --git a/crates/oakaudio/src/bridge/mod.rs b/crates/oakaudio/src/bridge/mod.rs
index 2d8491f47..de5389c9d 100644
--- a/crates/oakaudio/src/bridge/mod.rs
+++ b/crates/oakaudio/src/bridge/mod.rs
@@ -19,4 +19,3 @@
pub mod codec;
pub mod common;
-pub mod ffmpeg;
diff --git a/crates/oakaudio/src/processor.rs b/crates/oakaudio/src/processor.rs
index 6ab810b0d..abf7ec41e 100644
--- a/crates/oakaudio/src/processor.rs
+++ b/crates/oakaudio/src/processor.rs
@@ -16,22 +16,21 @@
//! 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
+//! Drives an in-process FFmpeg audio filter graph (abuffer → atempo chain →
+//! aformat → abuffersink) via ffmpeg-next; the C++ build went through the
+//! `fb_audio_graph_*`/`fb_frame_*` symbols of libffmpeg_bridge, which only
+//! existed to absorb FFmpeg API churn. 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 ffmpeg_next as ffmpeg;
+use ffmpeg::format::sample::Type as SampleType;
+use ffmpeg::format::Sample;
+use ffmpeg::{ChannelLayout, Error as FfmpegError};
+
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
use crate::params::{AudioParams, SampleFormat};
@@ -44,26 +43,26 @@ pub struct Processor {
/// Resampler state behind the handle's mutex.
struct ProcessorInner {
- /// Live filter graph (`null` = closed).
- graph: *mut AudioGraph,
+ /// Live filter graph (`None` = closed).
+ graph: Option,
/// Scratch output frame reused for every pull.
- out_frame: *mut Frame,
+ out_frame: ffmpeg::frame::Audio,
/// 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.
+// SAFETY: the filter graph's raw pointers are only dereferenced through the
+// FFmpeg API 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(),
+ graph: None,
+ out_frame: ffmpeg::frame::Audio::empty(),
from: AudioParams {
sample_rate: 0,
channel_layout: 0,
@@ -78,15 +77,41 @@ impl Default for ProcessorInner {
}
}
-/// `// 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);
+/// Map an oakcore [`SampleFormat`] to the equivalent ffmpeg [`Sample`].
+/// Replaces `FFmpegUtils::get_ffmpeg_sample_format` crossing the oakcommon
+/// C ABI (`// CPP-PARITY: src/common/src/ffmpegutils.cpp:83`).
+fn to_ffmpeg_sample_format(fmt: SampleFormat) -> Sample {
+ match fmt {
+ SampleFormat::U8Planar => Sample::U8(SampleType::Planar),
+ SampleFormat::S16Planar => Sample::I16(SampleType::Planar),
+ SampleFormat::S32Planar => Sample::I32(SampleType::Planar),
+ SampleFormat::S64Planar => Sample::I64(SampleType::Planar),
+ SampleFormat::F32Planar => Sample::F32(SampleType::Planar),
+ SampleFormat::F64Planar => Sample::F64(SampleType::Planar),
+ SampleFormat::U8 => Sample::U8(SampleType::Packed),
+ SampleFormat::S16 => Sample::I16(SampleType::Packed),
+ SampleFormat::S32 => Sample::I32(SampleType::Packed),
+ SampleFormat::S64 => Sample::I64(SampleType::Packed),
+ SampleFormat::F32 => Sample::F32(SampleType::Packed),
+ SampleFormat::F64 => Sample::F64(SampleType::Packed),
+ SampleFormat::Invalid => Sample::None,
}
- out
+}
+
+/// Rebuild an ffmpeg [`ChannelLayout`] from a channel mask (0 = unknown →
+/// stereo fallback). Same construction as oakcodec's
+/// `channel_layout_from_mask`.
+fn channel_layout_from_mask(mask: u64) -> ChannelLayout {
+ if mask == 0 {
+ return ChannelLayout::default(2);
+ }
+ let channels = mask.count_ones() as i32;
+ ChannelLayout(ffmpeg::ffi::AVChannelLayout {
+ order: ffmpeg::ffi::AVChannelOrder::AV_CHANNEL_ORDER_NATIVE,
+ nb_channels: channels,
+ u: ffmpeg::ffi::AVChannelLayout__bindgen_ty_1 { mask },
+ opaque: ptr::null_mut(),
+ })
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:50` — ensure a usable
@@ -99,11 +124,78 @@ fn fix_channel_layout(params: AudioParams) -> AudioParams {
if channels <= 0 {
channels = 2;
}
- result.channel_layout = unsafe { fb_channel_layout_default(channels) };
+ result.channel_layout = ChannelLayout::default(channels).bits();
}
result
}
+/// Build the conversion graph: abuffer → atempo chain → aformat (fltp at the
+/// output rate/layout) → abuffersink. `atempo` accepts factors in
+/// [0.5, 100], so out-of-range tempos are chained
+/// (`// CPP-PARITY: ffmpeg_bridge.cpp` `fb_audio_graph_create`).
+fn build_graph(from: &AudioParams, to: &AudioParams, speed: f64) -> Result {
+ let in_format = to_ffmpeg_sample_format(from.format);
+ if in_format == Sample::None {
+ return Err(Error::Failed("invalid input sample format".to_string()));
+ }
+
+ let abuffer = ffmpeg::filter::find("abuffer")
+ .ok_or_else(|| Error::Failed("abuffer filter not found".to_string()))?;
+ let abuffersink = ffmpeg::filter::find("abuffersink")
+ .ok_or_else(|| Error::Failed("abuffersink filter not found".to_string()))?;
+
+ let mut graph = ffmpeg::filter::Graph::new();
+ let in_args = format!(
+ "time_base=1/{rate}:sample_rate={rate}:sample_fmt={fmt}:channel_layout=0x{layout:x}",
+ rate = from.sample_rate,
+ fmt = in_format.name(),
+ layout = from.channel_layout,
+ );
+ graph
+ .add(&abuffer, "in", &in_args)
+ .map_err(|e| Error::Failed(format!("failed to add abuffer: {e}")))?;
+ graph
+ .add(&abuffersink, "out", "")
+ .map_err(|e| Error::Failed(format!("failed to add abuffersink: {e}")))?;
+
+ // Chain atempo for out-of-range factors, then force the output format
+ // (planar f32 at the requested rate/layout) with aformat.
+ let mut spec = String::new();
+ let mut tempo = speed;
+ while tempo > 100.0 {
+ spec.push_str("atempo=100.0,");
+ tempo /= 100.0;
+ }
+ while tempo < 0.5 {
+ spec.push_str("atempo=0.5,");
+ tempo /= 0.5;
+ }
+ if tempo != 1.0 {
+ spec.push_str(&format!("atempo={tempo},"));
+ }
+ spec.push_str(&format!(
+ "aformat=sample_fmts=fltp:sample_rates={}:channel_layouts=0x{:x}",
+ to.sample_rate, to.channel_layout,
+ ));
+
+ graph
+ .output("in", 0)
+ .and_then(|p| p.input("out", 0))
+ .and_then(|p| p.parse(&spec))
+ .map_err(|e| Error::Failed(format!("failed to parse filter spec: {e}")))?;
+ graph
+ .validate()
+ .map_err(|e| Error::Failed(format!("failed to validate filter graph: {e}")))?;
+ Ok(graph)
+}
+
+/// Whether a pull error just means "no output available right now" (needs
+/// more input, or the drained end after a flush).
+fn is_drain(e: &FfmpegError) -> bool {
+ matches!(e, FfmpegError::Eof)
+ || matches!(e, FfmpegError::Other { errno } if *errno == ffmpeg::error::EAGAIN)
+}
+
/// 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`.
@@ -137,7 +229,7 @@ pub fn open(
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
- if !inner.graph.is_null() {
+ if inner.graph.is_some() {
// C++: "tried to open a processor that was already open"
return Err(Error::State);
}
@@ -153,36 +245,11 @@ pub fn open(
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;
+ // C++: "failed to create audio filter graph"
+ let graph = build_graph(&from_fixed, &to_fixed, speed)?;
+ inner.graph = Some(graph);
+ inner.out_frame = ffmpeg::frame::Audio::empty();
inner.from = from_fixed;
inner.to = to_fixed;
Ok(())
@@ -193,12 +260,8 @@ 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) };
- }
+ inner.graph = None;
+ inner.out_frame = ffmpeg::frame::Audio::empty();
Ok(())
}
@@ -206,7 +269,7 @@ pub fn close(self_: &CHandle) -> Result<()> {
pub fn is_open(self_: &CHandle) -> Result {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
- Ok(!inner.graph.is_null())
+ Ok(inner.graph.is_some())
}
/// Push planar float input and pull converted output. Returns the number of
@@ -223,9 +286,10 @@ pub fn convert(
out_capacity_frames: i32,
) -> Result {
let p = get_processor(self_)?;
- let inner = p.inner.lock().unwrap();
+ let mut guard = p.inner.lock().unwrap();
+ let inner = &mut *guard;
- if inner.graph.is_null() {
+ if inner.graph.is_none() {
return Err(Error::State);
}
if in_frame_count < 0
@@ -240,24 +304,80 @@ pub fn convert(
return Err(Error::State);
}
+ let from = inner.from;
+ let graph = inner.graph.as_mut().unwrap();
+ let out_frame = &mut inner.out_frame;
+
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 nb = in_frame_count as usize;
+ let in_channels = from.channel_count().max(0) as usize;
+ let layout = channel_layout_from_mask(from.channel_layout);
+ let mut frame = ffmpeg::frame::Audio::new(
+ to_ffmpeg_sample_format(from.format),
+ nb,
+ layout,
+ );
+ frame.set_rate(from.sample_rate as u32);
+ let planar = from.format.is_planar();
+ // `plane_mut::` requires the exact sample type of the frame
+ // format, so the copy dispatches on the recorded input format.
+ macro_rules! fill {
+ ($t:ty) => {{
+ if planar {
+ for ch in 0..in_channels {
+ // SAFETY: `in_planar` is non-null here and the FFI
+ // contract guarantees at least `from.channel_count()`
+ // entries, each pointing at `nb` samples of the
+ // recorded input format.
+ let src = unsafe { *in_planar.add(ch) } as *const $t;
+ let dst = frame.plane_mut::<$t>(ch);
+ unsafe { ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), nb) };
+ }
+ } else {
+ // Packed input: a single plane at `in_planar[0]`.
+ // SAFETY: see above; the plane holds `nb * channels`
+ // samples.
+ let src = unsafe { *in_planar } as *const $t;
+ let dst = frame.plane_mut::<$t>(0);
+ unsafe {
+ ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), nb * in_channels)
+ };
+ }
+ }};
}
- let r =
- unsafe { fb_audio_graph_push(inner.graph, planes.as_ptr(), in_frame_count) };
- if r < 0 {
+ match from.format {
+ SampleFormat::U8Planar | SampleFormat::U8 => fill!(u8),
+ SampleFormat::S16Planar | SampleFormat::S16 => fill!(i16),
+ SampleFormat::S32Planar | SampleFormat::S32 => fill!(i32),
+ SampleFormat::S64Planar | SampleFormat::S64 => {
+ // ffmpeg-next's typed plane API has no `i64` impl; copy the
+ // 8-byte samples through the raw plane pointers.
+ if planar {
+ for ch in 0..in_channels {
+ // SAFETY: same contract as above; the plane is
+ // `nb * 8` bytes.
+ let src = unsafe { *in_planar.add(ch) } as *const u8;
+ let dst = unsafe { *(*frame.as_mut_ptr()).extended_data.add(ch) };
+ unsafe { ptr::copy_nonoverlapping(src, dst, nb * 8) };
+ }
+ } else {
+ // SAFETY: same contract as above; the plane is
+ // `nb * channels * 8` bytes.
+ let src = unsafe { *in_planar } as *const u8;
+ let dst = unsafe { *(*frame.as_mut_ptr()).extended_data };
+ unsafe { ptr::copy_nonoverlapping(src, dst, nb * in_channels * 8) };
+ }
+ }
+ SampleFormat::F32Planar | SampleFormat::F32 => fill!(f32),
+ SampleFormat::F64Planar | SampleFormat::F64 => fill!(f64),
+ SampleFormat::Invalid => return Err(Error::State),
+ }
+ if let Err(e) = graph.get("in").unwrap().source().add(&frame) {
return Err(Error::Failed(format!(
- "failed to add frame to buffersrc: {r}"
+ "failed to add frame to buffersrc: {e}"
)));
}
}
@@ -270,17 +390,18 @@ pub fn convert(
let mut total: i64 = 0;
loop {
- let r = unsafe { fb_audio_graph_pull(inner.graph, inner.out_frame) };
- if r <= 0 {
- if r < 0 {
+ let pulled = graph.get("out").unwrap().sink().frame(out_frame);
+ match pulled {
+ Ok(()) => {}
+ Err(e) if is_drain(&e) => break,
+ Err(e) => {
return Err(Error::Failed(format!(
- "failed to pull from buffersink: {r}"
- )));
+ "failed to pull from buffersink: {e}"
+ )))
}
- break;
}
- let nb = unsafe { fb_frame_get_nb_samples(inner.out_frame) };
+ let nb = out_frame.samples() as i32;
if nb > 0 && total < i64::from(out_capacity_frames) {
let to_copy =
(i64::from(out_capacity_frames) - total).min(i64::from(nb)) as i32;
@@ -293,12 +414,12 @@ pub fn convert(
}
// 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) };
+ let src = out_frame.plane::(ch as usize);
unsafe {
ptr::copy_nonoverlapping(
- src as *const u8,
- dst as *mut u8,
- (to_copy as usize) * 4,
+ src.as_ptr(),
+ dst,
+ to_copy as usize,
);
}
}
@@ -316,14 +437,12 @@ pub fn convert(
/// negative push return is logged only).
pub fn flush(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
- let inner = p.inner.lock().unwrap();
+ let mut inner = p.inner.lock().unwrap();
- if inner.graph.is_null() {
+ let Some(graph) = inner.graph.as_mut() else {
return Err(Error::State);
- }
- unsafe {
- fb_audio_graph_push(inner.graph, ptr::null(), 0);
- }
+ };
+ let _ = graph.get("in").unwrap().source().flush();
Ok(())
}
diff --git a/crates/oakaudio/src/waveform.rs b/crates/oakaudio/src/waveform.rs
index ae9125e0e..764962139 100644
--- a/crates/oakaudio/src/waveform.rs
+++ b/crates/oakaudio/src/waveform.rs
@@ -21,19 +21,13 @@
//! summarizes data.
use std::collections::BTreeMap;
-use std::ffi::{c_int, CStr};
+use std::ffi::CStr;
-use oakcore_rs::Rational;
+use oakcodec::decoder::{CodecStream, Decoder as _, RetrieveAudioStatus};
+use oakcodec::ffmpeg::FFmpegDecoder;
+use oakcore_rs::{Rational, TimeRange};
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};
@@ -653,17 +647,15 @@ pub struct ExtractOutcome {
pub channels: i32,
}
-/// Append a pulled frame's per-channel planar f32 samples to `pending`.
-fn append_pending(pending: &mut Vec>, 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.
+/// Append one decoded chunk's interleaved f32 samples to the per-channel
+/// `pending` planes.
+fn append_pending(pending: &mut Vec>, interleaved: &[f32], channels: i32) {
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);
+ let channels = channels.max(1) as usize;
+ for (i, &v) in interleaved.iter().enumerate() {
+ pending[i % channels].push(v);
}
}
@@ -706,9 +698,10 @@ fn emit_points(
/// 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.
+/// The stream is probed through the oakcodec decoder C ABI and decoded with
+/// oakcodec's in-process FFmpeg decoder (interleaved f32 at the native
+/// rate/layout), then reduced to one point per `samples_per_point` source
+/// samples.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:404`
/// (`oakaudio_waveform_extract`).
@@ -743,149 +736,70 @@ pub fn extract(filename: &CStr, stream_index: i32, samples_per_point: i32) -> Re
)));
}
- // 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}")));
+ // Decode the whole stream through oakcodec's FFmpeg decoder
+ // (`retrieve_audio` delivers interleaved f32 at the requested native
+ // rate/layout; the C++ path ran the decode through an identity
+ // fb_audio_graph to obtain planar f32).
+ let decoder = FFmpegDecoder::new();
+ let stream =
+ CodecStream::with_block(filename.to_string_lossy().into_owned(), info.stream_index, None);
+ if let Err(e) = decoder.open(&stream) {
+ return Err(Error::Failed(format!("failed to open decoder: {e:?}")));
}
- let mut sinfo = unsafe { std::mem::zeroed::() };
- 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(),
- ));
+ if info.time_base_num <= 0 || info.time_base_den <= 0 || info.duration_ts <= 0 {
+ let _ = decoder.close();
+ return Err(Error::Failed("invalid audio stream duration".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 duration_sec = info.duration_ts as f64
+ * f64::from(info.time_base_num)
+ / f64::from(info.time_base_den);
+ let total_frames = (duration_sec * f64::from(info.sample_rate)).round() as i64;
+ let layout_mask = if info.channel_layout != 0 {
+ info.channel_layout
+ } else {
+ ffmpeg_next::ChannelLayout::default(channels).bits()
};
- 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::new();
let mut points: Vec = Vec::new();
- // SAFETY: all handles are live; `frame` holds the decoded frame and
- // `converted` the graph output.
+ /// Frames decoded per `retrieve_audio` call (~0.34 s at 48 kHz).
+ const CHUNK_FRAMES: i64 = 16384;
+
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;
+ let mut offset = 0i64;
+ while offset < total_frames {
+ let frames = (total_frames - offset).min(CHUNK_FRAMES);
+ let range = TimeRange::new(
+ Rational::new(offset, i64::from(info.sample_rate)),
+ Rational::new(offset + frames, i64::from(info.sample_rate)),
+ );
+ let mut buf = vec![0f32; frames as usize * channels as usize];
+ match decoder.retrieve_audio(&mut buf, &range, info.sample_rate, layout_mask) {
+ Ok(RetrieveAudioStatus::Success) => {
+ append_pending(&mut pending, &buf, channels);
+ emit_points(&mut pending, channels, samples_per_point, &mut points, false);
}
- if pull == 0 {
+ Ok(status) => {
+ result = Err(Error::Failed(format!("audio retrieve failed: {status:?}")));
+ break;
+ }
+ Err(e) => {
+ result = Err(Error::Failed(format!("audio retrieve failed: {e:?}")));
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);
}
+ offset += frames;
}
-
- // 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 the trailing partial window.
emit_points(&mut pending, channels, samples_per_point, &mut points, true);
}
- cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
+ let _ = decoder.close();
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));
- }
-}
diff --git a/crates/oakaudio/tests/common/mod.rs b/crates/oakaudio/tests/common/mod.rs
index 41c71f1db..b4cd50841 100644
--- a/crates/oakaudio/tests/common/mod.rs
+++ b/crates/oakaudio/tests/common/mod.rs
@@ -16,12 +16,12 @@
//! Shared test helpers and bridge stubs for the oakaudio contract suite.
//!
-//! The library imports the other oak modules (`oakcommon`, `oakcodec`,
-//! `ffmpeg_bridge`) through `extern "C"` declarations in `bridge/`. A
-//! standalone `cargo test`/`cargo tarpaulin` run has no C++ objects to
-//! link, so [`mod stubs`] provides minimal definitions — real enough for
-//! the contract tests (a passthrough/linear filter graph, a WAV decoder,
-//! a no-op config/encoder) but by no means an ffmpeg replacement. The
+//! The library imports the other oak modules (`oakcommon`, `oakcodec`)
+//! through the `bridge/` wrappers. A standalone `cargo test` run has no
+//! host dylibs to link, so [`mod stubs`] provides minimal definitions — a
+//! no-op config/encoder and a WAV-header probe (real decoding in
+//! `waveform::extract` goes through oakcodec's in-process FFmpeg decoder;
+//! the processor drives a real FFmpeg filter graph via ffmpeg-next). The
//! exhaustive behavior matrix is pinned by the unchanged C++ gtest suite
//! (`src/audio/tests`).
@@ -133,7 +133,7 @@ pub mod stubs {
use std::path::Path;
use oakaudio::bridge::codec::AudioStreamInfo;
- use oakaudio::bridge::ffmpeg::{AudioGraphConfig, FBStreamInfo};
+ use oakaudio::handle::CHandle;
// ------------------------- oakcommon ---------------------------------
@@ -194,7 +194,7 @@ pub mod stubs {
0
}
- // ------------------------- ffmpeg_bridge ------------------------------
+ // ------------------------- oakcodec ----------------------------------
/// ffmpeg-style default channel layout mask for `nb_channels` (only the
/// popcount is load-bearing for oakaudio). Masks above 63 channels
@@ -209,314 +209,6 @@ pub mod stubs {
}
}
- #[no_mangle]
- pub extern "C" fn fb_channel_layout_get_channels(mask: u64) -> c_int {
- mask.count_ones() as c_int
- }
-
- #[no_mangle]
- pub extern "C" fn fb_channel_layout_default(nb_channels: c_int) -> u64 {
- layout_for(nb_channels)
- }
-
- /// A tiny deterministic filter graph: buffers planar-f32 (or packed s16)
- /// input and emits linearly-interpolated output at `out_rate` with an
- /// atempo-style tempo factor (tempo > 1 speeds up → fewer frames).
- struct StubGraph {
- in_rate: f64,
- out_rate: f64,
- in_channels: usize,
- out_channels: usize,
- in_format: c_int,
- tempo: f64,
- input: Vec>,
- emitted: usize,
- }
-
- impl StubGraph {
- fn available(&self) -> usize {
- let len = self.input.first().map_or(0, |c| c.len());
- if len == 0 {
- return 0;
- }
- let ratio = self.out_rate / (self.in_rate * self.tempo);
- ((len as f64 * ratio) + 1e-9).floor() as usize
- }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_audio_graph_create(config: *const AudioGraphConfig) -> *mut c_void {
- if config.is_null() {
- return std::ptr::null_mut();
- }
- // SAFETY: the caller guarantees a valid config pointer.
- let c = unsafe { &*config };
- if c.in_sample_rate <= 0
- || c.out_sample_rate <= 0
- || c.in_channels <= 0
- || c.out_channels <= 0
- {
- return std::ptr::null_mut();
- }
- let g = StubGraph {
- in_rate: f64::from(c.in_sample_rate),
- out_rate: f64::from(c.out_sample_rate),
- in_channels: c.in_channels as usize,
- out_channels: c.out_channels as usize,
- in_format: c.in_sample_format,
- tempo: c.tempo.max(0.001),
- input: vec![Vec::new(); c.in_channels as usize],
- emitted: 0,
- };
- Box::into_raw(Box::new(g)) as *mut c_void
- }
-
- #[no_mangle]
- pub extern "C" fn fb_audio_graph_free(graph: *mut *mut c_void) {
- if !graph.is_null() && !(unsafe { *graph }).is_null() {
- // SAFETY: the pointer was created by `fb_audio_graph_create`.
- drop(unsafe { Box::from_raw(*graph as *mut StubGraph) });
- // SAFETY: the double-pointer belongs to the caller.
- unsafe { *graph = std::ptr::null_mut() };
- }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_audio_graph_push(
- graph: *mut c_void,
- channel_data: *const *const u8,
- nb_samples: c_int,
- ) -> c_int {
- if graph.is_null() || nb_samples < 0 {
- return -1;
- }
- // SAFETY: the graph pointer was created by `fb_audio_graph_create`
- // and is still live.
- let g = unsafe { &mut *(graph as *mut StubGraph) };
- if channel_data.is_null() {
- return 0; // flush marker
- }
- for f in 0..nb_samples as usize {
- for c in 0..g.in_channels {
- let v = match g.in_format {
- 8 => {
- // fltp: one f32 plane per channel.
- // SAFETY: the caller guarantees `nb_samples` floats
- // per plane.
- let p = unsafe { *channel_data.add(c) } as *const f32;
- unsafe { *p.add(f) }
- }
- 1 => {
- // s16 packed: interleaved in plane 0.
- // SAFETY: the caller guarantees `nb_samples *
- // channels * 2` bytes in plane 0.
- let p = unsafe { *channel_data } as *const u8;
- let off = (f * g.in_channels + c) * 2;
- let lo = unsafe { *p.add(off) };
- let hi = unsafe { *p.add(off + 1) };
- f32::from(i16::from_le_bytes([lo, hi])) / 32768.0
- }
- _ => 0.0,
- };
- g.input[c].push(v);
- }
- }
- 0
- }
-
- /// A frame payload: per-plane raw bytes plus sample/format metadata.
- struct StubFrame {
- nb: i32,
- channels: i32,
- format: c_int,
- rate: c_int,
- layout: u64,
- data: Vec>,
- }
-
- impl Default for StubFrame {
- fn default() -> Self {
- StubFrame {
- nb: 0,
- channels: 0,
- format: -1,
- rate: 0,
- layout: 0,
- data: Vec::new(),
- }
- }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_audio_graph_pull(graph: *mut c_void, out_frame: *mut c_void) -> c_int {
- if graph.is_null() || out_frame.is_null() {
- return -1;
- }
- // SAFETY: both pointers are live (created by the allocators below).
- let g = unsafe { &mut *(graph as *mut StubGraph) };
- let out = unsafe { &mut *(out_frame as *mut StubFrame) };
-
- let total = g.available();
- if g.emitted >= total {
- return 0;
- }
- let nb = total - g.emitted;
-
- out.nb = nb as i32;
- out.channels = g.out_channels as i32;
- out.format = 8; // fltp
- out.data = vec![vec![0u8; nb * 4]; g.out_channels];
-
- for o in 0..nb {
- let pos = o as f64 * g.in_rate / g.out_rate * g.tempo;
- for c in 0..g.out_channels {
- let lower = (pos.floor() as usize).min(g.input[c].len() - 1);
- let upper = (lower + 1).min(g.input[c].len() - 1);
- let frac = pos - lower as f64;
- let v = f64::from(g.input[c][lower]) * (1.0 - frac)
- + f64::from(g.input[c][upper]) * frac;
- let bytes = (v as f32).to_le_bytes();
- let off = o * 4;
- out.data[c][off..off + 4].copy_from_slice(&bytes);
- }
- }
- g.emitted = total;
- 1
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_alloc() -> *mut c_void {
- Box::into_raw(Box::new(StubFrame::default())) as *mut c_void
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_free(frame: *mut *mut c_void) {
- if !frame.is_null() && !(unsafe { *frame }).is_null() {
- // SAFETY: the pointer was created by `fb_frame_alloc`.
- drop(unsafe { Box::from_raw(*frame as *mut StubFrame) });
- // SAFETY: the double-pointer belongs to the caller.
- unsafe { *frame = std::ptr::null_mut() };
- }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_unref(_frame: *mut c_void) {}
-
- #[no_mangle]
- pub extern "C" fn fb_frame_get_nb_samples(frame: *const c_void) -> c_int {
- if frame.is_null() {
- return 0;
- }
- // SAFETY: the pointer is a live `StubFrame`.
- unsafe { (*(frame as *const StubFrame)).nb }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_set_nb_samples(frame: *mut c_void, nb_samples: c_int) {
- if frame.is_null() {
- return;
- }
- // SAFETY: the pointer is a live `StubFrame`.
- unsafe { (*(frame as *mut StubFrame)).nb = nb_samples };
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_get_sample_rate(frame: *const c_void) -> c_int {
- if frame.is_null() {
- return 0;
- }
- // SAFETY: the pointer is a live `StubFrame`.
- unsafe { (*(frame as *const StubFrame)).rate }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_get_format(frame: *const c_void) -> c_int {
- if frame.is_null() {
- return -1;
- }
- // SAFETY: the pointer is a live `StubFrame`.
- unsafe { (*(frame as *const StubFrame)).format }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_get_channel_layout_mask(frame: *const c_void) -> u64 {
- if frame.is_null() {
- return 0;
- }
- // SAFETY: the pointer is a live `StubFrame`.
- unsafe { (*(frame as *const StubFrame)).layout }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_get_data(frame: *mut c_void, plane: c_int) -> *mut u8 {
- if frame.is_null() {
- return std::ptr::null_mut();
- }
- // SAFETY: the pointer is a live `StubFrame`.
- let f = unsafe { &mut *(frame as *mut StubFrame) };
- match f.data.get_mut(plane as usize) {
- Some(v) => v.as_mut_ptr(),
- None => std::ptr::null_mut(),
- }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_get_data_const(frame: *const c_void, plane: c_int) -> *const u8 {
- if frame.is_null() {
- return std::ptr::null();
- }
- // SAFETY: the pointer is a live `StubFrame`.
- let f = unsafe { &*(frame as *const StubFrame) };
- match f.data.get(plane as usize) {
- Some(v) => v.as_ptr(),
- None => std::ptr::null(),
- }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_frame_get_linesize(frame: *const c_void, _plane: c_int) -> c_int {
- if frame.is_null() {
- return 0;
- }
- // SAFETY: the pointer is a live `StubFrame`.
- unsafe { (*(frame as *const StubFrame)).nb * 4 }
- }
-
- /// A raw packet payload (unused by oakaudio, kept for completeness).
- struct StubPacket {
- data: Vec,
- }
-
- #[no_mangle]
- pub extern "C" fn fb_packet_alloc() -> *mut c_void {
- Box::into_raw(Box::new(StubPacket { data: Vec::new() })) as *mut c_void
- }
-
- #[no_mangle]
- pub extern "C" fn fb_packet_free(packet: *mut *mut c_void) {
- if !packet.is_null() && !(unsafe { *packet }).is_null() {
- // SAFETY: the pointer was created by `fb_packet_alloc`.
- drop(unsafe { Box::from_raw(*packet as *mut StubPacket) });
- // SAFETY: the double-pointer belongs to the caller.
- unsafe { *packet = std::ptr::null_mut() };
- }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_packet_unref(_packet: *mut c_void) {}
-
- /// 16-bit PCM WAV stream state (the only format the fixture writer
- /// produces).
- struct StubDecoder {
- file: Option,
- channels: i32,
- sample_rate: i32,
- block_align: usize,
- remaining: usize,
- total_frames: i64,
- layout: u64,
- }
-
/// WAV header facts parsed by `parse_wav`.
struct WavInfo {
channels: i32,
@@ -556,202 +248,34 @@ pub mod stubs {
})
}
- #[no_mangle]
- pub extern "C" fn fb_decoder_create() -> *mut c_void {
- Box::into_raw(Box::new(StubDecoder {
- file: None,
- channels: 0,
- sample_rate: 0,
- block_align: 0,
- remaining: 0,
- total_frames: 0,
- layout: 0,
- })) as *mut c_void
- }
-
- #[no_mangle]
- pub extern "C" fn fb_decoder_open(
- decoder: *mut c_void,
- filename: *const c_char,
- stream_index: c_int,
- ) -> c_int {
- if decoder.is_null() || filename.is_null() {
- return -1;
- }
- // SAFETY: the C string is NUL-terminated (caller contract).
- let cname = unsafe { CStr::from_ptr(filename) };
- let path = Path::new(cname.to_str().unwrap_or(""));
- match parse_wav(path) {
- Some(info) if stream_index == 0 => {
- // SAFETY: the decoder pointer is a live `StubDecoder`.
- let d = unsafe { &mut *(decoder as *mut StubDecoder) };
- d.file = std::fs::File::open(path).ok();
- // The file cursor starts at 0; skip the 44-byte WAV header
- // so reads land on the data chunk (decoder_read_chunk reads
- // exactly `remaining` data bytes).
- if let Some(f) = d.file.as_mut() {
- use std::io::{Seek, SeekFrom};
- let _ = f.seek(SeekFrom::Start(44));
- }
- d.channels = info.channels;
- d.sample_rate = info.rate;
- d.block_align = info.block_align;
- d.remaining = (info.frames as usize) * info.block_align;
- d.total_frames = info.frames;
- d.layout = layout_for(info.channels);
- 0
- }
- _ => -1,
- }
- }
-
- #[no_mangle]
- pub extern "C" fn fb_decoder_close(_decoder: *mut c_void) {}
-
- #[no_mangle]
- pub extern "C" fn fb_decoder_free(decoder: *mut *mut c_void) {
- if !decoder.is_null() && !(unsafe { *decoder }).is_null() {
- // SAFETY: the pointer was created by `fb_decoder_create`.
- drop(unsafe { Box::from_raw(*decoder as *mut StubDecoder) });
- // SAFETY: the double-pointer belongs to the caller.
- unsafe { *decoder = std::ptr::null_mut() };
- }
- }
-
- /// Read up to `max` whole frames of interleaved s16 PCM.
- fn decoder_read_chunk(d: &mut StubDecoder, max_bytes: usize) -> Vec {
- use std::io::Read;
- if d.file.is_none() || d.remaining == 0 {
- return Vec::new();
- }
- let mut buf = vec![0u8; max_bytes.min(d.remaining)];
- let f = d.file.as_mut().unwrap();
- let mut n = 0usize;
- while n < buf.len() {
- match f.read(&mut buf[n..]) {
- Ok(0) => break,
- Ok(read) => n += read,
- Err(_) => break,
- }
- }
- d.remaining = d.remaining.saturating_sub(n);
- // Keep only whole frames.
- let whole = n / d.block_align * d.block_align;
- buf.truncate(whole);
- buf
- }
-
- #[no_mangle]
- pub extern "C" fn fb_decoder_get_frame(
- decoder: *mut c_void,
- _packet: *mut c_void,
- frame: *mut c_void,
- ) -> c_int {
- if decoder.is_null() || frame.is_null() {
- return -1;
- }
- // SAFETY: both pointers are live stubs.
- let d = unsafe { &mut *(decoder as *mut StubDecoder) };
- let f = unsafe { &mut *(frame as *mut StubFrame) };
- let chunk = decoder_read_chunk(d, 4096);
- if chunk.is_empty() {
- return -1; // EOF
- }
- f.nb = (chunk.len() / d.block_align) as i32;
- f.channels = d.channels;
- f.format = 1; // s16 packed (native WAV format)
- f.rate = d.sample_rate;
- f.layout = d.layout;
- f.data = vec![chunk];
- 0
- }
-
- #[no_mangle]
- pub extern "C" fn fb_decoder_get_packet(decoder: *mut c_void, packet: *mut c_void) -> c_int {
- if decoder.is_null() || packet.is_null() {
- return -1;
- }
- // SAFETY: both pointers are live stubs.
- let d = unsafe { &mut *(decoder as *mut StubDecoder) };
- let p = unsafe { &mut *(packet as *mut StubPacket) };
- let chunk = decoder_read_chunk(d, 4096);
- if chunk.is_empty() {
- return -1;
- }
- p.data = chunk;
- 0
- }
-
- #[no_mangle]
- pub extern "C" fn fb_decoder_get_stream_info(decoder: *const c_void, out: *mut FBStreamInfo) -> c_int {
- if decoder.is_null() || out.is_null() {
- return -1;
- }
- // SAFETY: the decoder pointer is a live `StubDecoder`; `out` is a
- // caller-owned info struct.
- let d = unsafe { &*(decoder as *const StubDecoder) };
- unsafe {
- (*out).index = 0;
- (*out).codec_type = 1; // audio
- (*out).codec_id = 0;
- (*out).has_decoder = 1;
- (*out).width = 0;
- (*out).height = 0;
- (*out).pixel_format = -1;
- (*out).field_order = 0;
- (*out).color_range = 0;
- (*out).color_primaries = 2;
- (*out).color_trc = 2;
- (*out).sample_rate = d.sample_rate;
- (*out).sample_format = 1; // s16
- (*out).channel_layout_mask = d.layout;
- (*out).start_time = 0;
- (*out).duration = d.total_frames;
- (*out).time_base_num = 1;
- (*out).time_base_den = d.sample_rate.max(1);
- (*out).avg_frame_rate_num = 0;
- (*out).avg_frame_rate_den = 0;
- }
- 0
- }
-
- #[no_mangle]
- pub extern "C" fn fb_decoder_get_format_start_time(decoder: *const c_void) -> i64 {
- if decoder.is_null() {
- return 0;
- }
- 0
- }
-
- #[no_mangle]
- pub extern "C" fn fb_decoder_get_format_duration(decoder: *const c_void) -> i64 {
- if decoder.is_null() {
- return 0;
- }
- // SAFETY: the decoder pointer is a live `StubDecoder`.
- unsafe { (*(decoder as *const StubDecoder)).total_frames }
- }
-
- // ------------------------- oakcodec ----------------------------------
+ // All handles below use the shared `CHandle` ABI (single-lib
+ // unification): oakaudio's `bridge::codec` wrappers call the oakcodec
+ // crate's `#[no_mangle]` ffi functions directly, so these stubs must
+ // match the real oakcodec ffi signatures exactly.
struct StubEncoder;
#[no_mangle]
- pub extern "C" fn oakcodec_encoder_init(params: *const c_void) -> *mut c_void {
+ pub extern "C" fn oakcodec_encoder_init(params: *const c_void) -> CHandle {
if params.is_null() {
- return std::ptr::null_mut();
+ return CHandle::null();
+ }
+ CHandle {
+ ctx: Box::into_raw(Box::new(StubEncoder)) as *mut c_void,
+ addref: None,
+ release: None,
+ abi_version: 0,
}
- Box::into_raw(Box::new(StubEncoder)) as *mut c_void
}
#[no_mangle]
- pub extern "C" fn oakcodec_encoder_open(_encoder: *mut c_void) -> c_int {
+ pub extern "C" fn oakcodec_encoder_open(_encoder: CHandle) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_write_audio(
- _encoder: *mut c_void,
+ _encoder: CHandle,
_samples: *const f32,
_frame_count: c_int,
) -> c_int {
@@ -759,13 +283,13 @@ pub mod stubs {
}
#[no_mangle]
- pub extern "C" fn oakcodec_encoder_flush(_encoder: *mut c_void) -> c_int {
+ pub extern "C" fn oakcodec_encoder_flush(_encoder: CHandle) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_last_error(
- _encoder: *mut c_void,
+ _encoder: CHandle,
_buf: *mut c_char,
_buf_size: c_int,
) -> c_int {
@@ -773,10 +297,15 @@ pub mod stubs {
}
#[no_mangle]
- pub extern "C" fn oakcodec_encoder_free(encoder: *mut c_void) {
+ pub extern "C" fn oakcodec_encoder_free(encoder: *mut CHandle) {
if !encoder.is_null() {
- // SAFETY: the pointer was created by `oakcodec_encoder_init`.
- drop(unsafe { Box::from_raw(encoder as *mut StubEncoder) });
+ // SAFETY: the pointer was created by `oakcodec_encoder_init`;
+ // the caller owns `encoder` and expects it cleared.
+ let e = unsafe { &mut *encoder };
+ if !e.ctx.is_null() {
+ drop(unsafe { Box::from_raw(e.ctx as *mut StubEncoder) });
+ e.ctx = std::ptr::null_mut();
+ }
}
}
@@ -784,58 +313,64 @@ pub mod stubs {
struct StubProbe {
channels: i32,
sample_rate: i32,
- block_align: usize,
frames: i64,
layout: u64,
}
#[no_mangle]
- pub extern "C" fn oakcodec_decoder_probe(filename: *const c_char) -> *mut c_void {
+ pub extern "C" fn oakcodec_decoder_probe(filename: *const c_char) -> CHandle {
if filename.is_null() {
- return std::ptr::null_mut();
+ return CHandle::null();
}
// SAFETY: the C string is NUL-terminated (caller contract).
let cname = unsafe { CStr::from_ptr(filename) };
let path = Path::new(cname.to_str().unwrap_or(""));
match parse_wav(path) {
- Some(info) => {
- Box::into_raw(Box::new(StubProbe {
+ Some(info) => CHandle {
+ ctx: Box::into_raw(Box::new(StubProbe {
channels: info.channels,
sample_rate: info.rate,
- block_align: info.block_align,
frames: info.frames,
layout: layout_for(info.channels),
- })) as *mut c_void
- }
- None => std::ptr::null_mut(),
+ })) as *mut c_void,
+ addref: None,
+ release: None,
+ abi_version: 0,
+ },
+ None => CHandle::null(),
}
}
#[no_mangle]
- pub extern "C" fn oakcodec_decoder_free(probe: *mut c_void) {
+ pub extern "C" fn oakcodec_decoder_free(probe: *mut CHandle) {
if !probe.is_null() {
- // SAFETY: the pointer was created by `oakcodec_decoder_probe`.
- drop(unsafe { Box::from_raw(probe as *mut StubProbe) });
+ // SAFETY: the pointer was created by `oakcodec_decoder_probe`;
+ // the caller owns `probe` and expects it cleared.
+ let p = unsafe { &mut *probe };
+ if !p.ctx.is_null() {
+ drop(unsafe { Box::from_raw(p.ctx as *mut StubProbe) });
+ p.ctx = std::ptr::null_mut();
+ }
}
}
#[no_mangle]
- pub extern "C" fn oakcodec_decoder_probe_audio_stream_count(_probe: *mut c_void) -> c_int {
+ pub extern "C" fn oakcodec_decoder_probe_audio_stream_count(_probe: CHandle) -> c_int {
1
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe_get_audio_stream(
- probe: *mut c_void,
+ probe: CHandle,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int {
- if probe.is_null() || out.is_null() || index != 0 {
+ if probe.ctx.is_null() || out.is_null() || index != 0 {
return -1;
}
- // SAFETY: the probe pointer is a live `StubProbe`; `out` is a
+ // SAFETY: the probe ctx is a live `StubProbe`; `out` is a
// caller-owned info struct.
- let p = unsafe { &*(probe as *const StubProbe) };
+ let p = unsafe { &*(probe.ctx as *const StubProbe) };
unsafe {
(*out).stream_index = 0;
(*out).sample_rate = p.sample_rate;
@@ -850,7 +385,7 @@ pub mod stubs {
#[no_mangle]
pub extern "C" fn oakcodec_decoder_open(
- _decoder: *mut c_void,
+ _decoder: CHandle,
_filename: *const c_char,
_stream_index: c_int,
) -> c_int {
@@ -859,7 +394,7 @@ pub mod stubs {
#[no_mangle]
pub extern "C" fn oakcodec_decoder_decode_audio(
- _decoder: *mut c_void,
+ _decoder: CHandle,
_in_num: c_int,
_in_den: c_int,
_out_num: c_int,
diff --git a/crates/oakaudio/tests/processor_test.rs b/crates/oakaudio/tests/processor_test.rs
index cc236064d..3163e260d 100644
--- a/crates/oakaudio/tests/processor_test.rs
+++ b/crates/oakaudio/tests/processor_test.rs
@@ -15,9 +15,10 @@
// along with this program. If not, see .
//! AudioProcessor contract tests (processor.rs), through the C ABI. The
-//! test stub filter graph resamples and time-stretches like the real
-//! ffmpeg_bridge graph (linear interpolation), so frame-count contracts
-//! are pinned exactly.
+//! conversion runs a real FFmpeg filter graph (aresample/aformat/atempo),
+//! so resampling and time-stretch have filter latency: the exact frame
+//! counts are drained after `flush`, while identity conversion is an
+//! immediate passthrough.
mod common;
@@ -158,9 +159,9 @@ fn open_invalid_params() {
unsafe { oakaudio_processor_free(&mut h) };
}
-/// Resampling to half rate halves the frame count (44100 -> 22050, 32 input
-/// frames produce 16 output frames); flush is a no-op on the drained graph
-/// and keeps the processor open.
+/// Resampling to half rate halves the frame count (44100 -> 22050). The
+/// real resampler holds samples back (filter delay), so the frames are
+/// drained after `flush`; flush then keeps the processor open.
#[test]
fn resample_and_flush() {
let mut h = unsafe { oakaudio_processor_init() };
@@ -169,22 +170,44 @@ fn resample_and_flush() {
OAKAUDIO_OK
);
- let planes = ramp_planes(32);
+ // 1 second of input keeps the resampler delay well below the signal.
+ let frames = 44100;
+ let planes = ramp_planes(frames);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
- let mut out = vec![vec![0f32; 32]; 2];
+ let mut out = vec![vec![0f32; frames]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
- let n = unsafe {
- oakaudio_processor_convert(h, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 32)
- };
- assert_eq!(n, 16, "half-rate output must halve the frame count");
+ let mut total = unsafe {
+ oakaudio_processor_convert(h, in_ptrs.as_ptr(), frames as i32, out_ptrs.as_ptr(), frames as i32)
+ };
assert_eq!(unsafe { oakaudio_processor_flush(h) }, OAKAUDIO_OK);
+ // Drain the resampler delay after end-of-input.
+ while total < frames as i32 {
+ let n = unsafe {
+ oakaudio_processor_convert(
+ h,
+ std::ptr::null(),
+ 0,
+ out_ptrs.as_ptr(),
+ frames as i32,
+ )
+ };
+ if n == 0 {
+ break;
+ }
+ total += n;
+ }
+ assert!(
+ (total - 22050).abs() <= 2,
+ "half-rate output must halve the frame count (got {total})"
+ );
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
unsafe { oakaudio_processor_free(&mut h) };
}
/// A tempo factor != 1.0 time-stretches: tempo 2.0 halves the frame count
-/// and the processor stays open.
+/// (drained after `flush`; atempo needs a full analysis window before it
+/// produces output) and the processor stays open.
#[test]
fn tempo_stretch() {
let mut h = unsafe { oakaudio_processor_init() };
@@ -193,14 +216,36 @@ fn tempo_stretch() {
OAKAUDIO_OK
);
- let planes = ramp_planes(32);
+ // 1 second of input: many atempo windows (1024 samples at 48 kHz).
+ let frames = 48000;
+ let planes = ramp_planes(frames);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
- let mut out = vec![vec![0f32; 32]; 2];
+ let mut out = vec![vec![0f32; frames]; 2];
let mut out_ptrs: Vec<*mut f32> = out.iter_mut().map(|p| p.as_mut_ptr()).collect();
- let n = unsafe {
- oakaudio_processor_convert(h, in_ptrs.as_ptr(), 32, out_ptrs.as_ptr(), 32)
+
+ let mut total = unsafe {
+ oakaudio_processor_convert(h, in_ptrs.as_ptr(), frames as i32, out_ptrs.as_ptr(), frames as i32)
};
- assert_eq!(n, 16, "tempo 2.0 must halve the frame count");
+ assert_eq!(unsafe { oakaudio_processor_flush(h) }, OAKAUDIO_OK);
+ while total < frames as i32 {
+ let n = unsafe {
+ oakaudio_processor_convert(
+ h,
+ std::ptr::null(),
+ 0,
+ out_ptrs.as_ptr(),
+ frames as i32,
+ )
+ };
+ if n == 0 {
+ break;
+ }
+ total += n;
+ }
+ assert!(
+ (total - 24000).abs() <= 2400,
+ "tempo 2.0 must halve the frame count (got {total})"
+ );
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
unsafe { oakaudio_processor_close(h) };
diff --git a/crates/oakaudio/tests/waveform_test.rs b/crates/oakaudio/tests/waveform_test.rs
index 55b957d94..2b3d3b960 100644
--- a/crates/oakaudio/tests/waveform_test.rs
+++ b/crates/oakaudio/tests/waveform_test.rs
@@ -250,11 +250,9 @@ fn sum_and_resum_golden() {
}
/// extract probes through the oakcodec decoder C ABI; a missing file
-/// returns OAKAUDIO_E_NOT_FOUND. The full decode of a real file goes
-/// through the host ffmpeg_bridge (`fb_*`, a C++ library not linked into
-/// the Rust-only test binary), so the valid-file pixel assertions are
-/// covered by the ffmpeg_bridge/audio integration tests instead; this
-/// test pins the probe error path.
+/// returns OAKAUDIO_E_NOT_FOUND. The valid-file path decodes the fixture
+/// WAV with oakcodec's in-process FFmpeg decoder and reduces it to min/max
+/// points.
#[test]
fn extract_file_and_notfound() {
let path = std::env::temp_dir().join(format!(
@@ -290,6 +288,40 @@ fn extract_file_and_notfound() {
};
assert_eq!(r, -60004);
+ // Real decode: 8 frames at 4 samples/point -> 2 points, 2 channels.
+ let c_path = CString::new(path.to_str().unwrap()).unwrap();
+ let mut channel_count = 0i32;
+ let n = unsafe {
+ oakaudio_waveform_extract(
+ c_path.as_ptr(),
+ 0,
+ 4,
+ std::ptr::null_mut(),
+ 0,
+ &mut channel_count,
+ )
+ };
+ assert_eq!(channel_count, 2);
+ assert_eq!(n, 2, "8 frames at 4 samples/point must yield 2 points");
+
+ let mut out = vec![MinMax { min: 0.0, max: 0.0 }; 4];
+ let n = unsafe {
+ oakaudio_waveform_extract(c_path.as_ptr(), 0, 4, out.as_mut_ptr(), 2, &mut channel_count)
+ };
+ assert_eq!(n, 2);
+ // s16 -> f32 is /32768; the ramp is exact in both formats.
+ let eps = 1e-6;
+ // Point 0 covers frames 0..4: left 0..3000, right 0..-3000.
+ assert!((out[0].min - 0.0).abs() < eps);
+ assert!((out[0].max - 3000.0 / 32768.0).abs() < eps);
+ assert!((out[1].min - -3000.0 / 32768.0).abs() < eps);
+ assert!((out[1].max - 0.0).abs() < eps);
+ // Point 1 covers frames 4..8: left 4000..7000, right -4000..-7000.
+ assert!((out[2].min - 4000.0 / 32768.0).abs() < eps);
+ assert!((out[2].max - 7000.0 / 32768.0).abs() < eps);
+ assert!((out[3].min - -7000.0 / 32768.0).abs() < eps);
+ assert!((out[3].max - -4000.0 / 32768.0).abs() < eps);
+
std::fs::remove_file(&path).ok();
}
diff --git a/crates/oakcommon/src/ffmpegutils.rs b/crates/oakcommon/src/ffmpegutils.rs
index c8047612e..c961881b9 100644
--- a/crates/oakcommon/src/ffmpegutils.rs
+++ b/crates/oakcommon/src/ffmpegutils.rs
@@ -19,9 +19,9 @@
//! Mirrors `src/common/src/ffmpegutils.h` and
//! `include/common/ffmpegutils.h`. There is no handle to create or free.
//!
-//! The bridge constants are only reachable through narrow `extern "C"`
-//! blocks into ffmpeg_bridge; native formats are plain ints matching
-//! `olive::core` enum values.
+//! The bridge constants are plain ints copied verbatim from the ffmpeg_bridge
+//! header (kept for C ABI compatibility); native formats are plain ints
+//! matching `olive::core` enum values.
/// RGB channel count (flattened from `VideoParams`).
pub const RGB_CHANNEL_COUNT: i32 = 3;
@@ -106,53 +106,26 @@ const FB_SAMPLE_FMT_DBLP: i32 = 9;
const FB_SAMPLE_FMT_S64: i32 = 10;
const FB_SAMPLE_FMT_S64_P: i32 = 11;
-/// Calls the bridge's best-pixel-format search on `list` — picks the entry of
-/// a `FB_PIX_FMT_NONE`-terminated list closest to `pix_fmt`. In test builds
-/// (`cfg(test)` or feature `test-stubs`) the bridge is not linked, so a small
-/// faithful-in-spirit stub is used; the real loss-metric selection lives in
-/// ffmpeg_bridge and is only reachable in the final application build.
-///
-/// # CPP-PARITY
-/// The real `fb_find_best_pix_fmt_of_list` symbol (`ffmpeg_bridge` C ABI)
-/// has the same signature and `NONE`-terminated-list semantics as
-/// `fb_find_best_pix_fmt_of_list` in `ffmpeg_bridge/src/utils.cpp`.
+/// Picks the entry of a `FB_PIX_FMT_NONE`-terminated candidate list closest
+/// to `pix_fmt`: an exact match wins, otherwise the first (most desirable)
+/// candidate — for this module's RGB-only candidate lists that is also what
+/// the bridge's loss-metric selection (`avcodec_find_best_pix_fmt_of_list`
+/// via the former `fb_find_best_pix_fmt_of_list` C ABI import) produces.
+/// The logic now runs in-process; nothing links ffmpeg_bridge anymore.
fn find_best_pix_fmt_of_list(list: &[i32; 4], pix_fmt: i32) -> i32 {
- // The bridge library is only linked into the final application, never into
- // a Rust test binary. Unit tests activate the stub via `cfg(test)`; the
- // integration tests (tests/ffi_ffmpegutils.rs) opt in with the
- // `test-stubs` cargo feature.
- #[cfg(all(not(test), not(feature = "test-stubs")))]
- extern "C" {
- fn fb_find_best_pix_fmt_of_list(
- list: *const std::ffi::c_int,
- pix_fmt: std::ffi::c_int,
- ) -> std::ffi::c_int;
- }
- #[cfg(all(not(test), not(feature = "test-stubs")))]
- {
- // SAFETY: `list` is a `FB_PIX_FMT_NONE`-terminated array that outlives
- // the call; `pix_fmt` is any valid bridge pixel format.
- unsafe { fb_find_best_pix_fmt_of_list(list.as_ptr(), pix_fmt) }
- }
- #[cfg(any(test, feature = "test-stubs"))]
- {
- // Stub: exact matches are preferred, otherwise the first (most
- // desirable) candidate is returned, matching the bridge's behaviour
- // for an unknown source format. Only used by test builds.
- for &candidate in list {
- if candidate == pix_fmt {
- return candidate;
- }
- if candidate == FB_PIX_FMT_NONE {
- break;
- }
+ for &candidate in list {
+ if candidate == pix_fmt {
+ return candidate;
}
- if list[0] == FB_PIX_FMT_NONE {
- FB_PIX_FMT_NONE
- } else {
- list[0]
+ if candidate == FB_PIX_FMT_NONE {
+ break;
}
}
+ if list[0] == FB_PIX_FMT_NONE {
+ FB_PIX_FMT_NONE
+ } else {
+ list[0]
+ }
}
/// Builds the `FB_PIX_FMT_NONE`-terminated candidate list for
diff --git a/crates/oakengine/Cargo.toml b/crates/oakengine/Cargo.toml
index f92d5617a..eb72d0d58 100644
--- a/crates/oakengine/Cargo.toml
+++ b/crates/oakengine/Cargo.toml
@@ -29,8 +29,10 @@ libc = "0.2"
# dlsym(RTLD_DEFAULT) (oaknode, oakplugin, oakrender) now resolve against
# the sibling modules inside the same dylib; the remaining undefined
# imports are the C++ host-provided symbols (`oakcore_audioparams_*`,
-# `oakcore_rational_*` from liboakcore, `fb_*` from ffmpeg_bridge), which
-# build.rs leaves as runtime lookups for the host app.
+# `oakcore_rational_*` from liboakcore), which build.rs leaves as runtime
+# lookups for the host app. The audio resample/decode paths call FFmpeg
+# in-process via ffmpeg-next (oakaudio/oakcodec); the C++ ffmpeg_bridge
+# `fb_*` imports are gone.
#
# Tests link the same crates; the dev-dependencies below re-declare
# oakcommon/oakplugin WITH `test-stubs` so their in-crate C ABI mocks
@@ -61,10 +63,9 @@ oaknode = { path = "../oaknode" }
[dev-dependencies]
# The module crates as plain dependencies (no `test-stubs`): the C ABI
-# mocks the test binaries need (the ffmpeg_bridge `fb_*` replacement and
-# the oakcore_* symbols) are provided by the crate's own test support
-# (tests/common/mod.rs), which is force-linked into every integration
-# test. Keeping the dev-deps feature-free also stops Cargo's workspace
+# mocks the test binaries need (the oakcore_* symbols) are provided by
+# the crate's own test support (tests/common/mod.rs), which is
+# force-linked into every integration test. Keeping the dev-deps feature-free also stops Cargo's workspace
# feature unification from forcing `test-stubs` onto the oakcommon/oakplugin
# member builds during `cargo test --workspace` (their own tests then run
# in the real dlsym mode and stay green).
diff --git a/crates/oakengine/tests/common/mod.rs b/crates/oakengine/tests/common/mod.rs
index cab0601fe..df6de65ee 100644
--- a/crates/oakengine/tests/common/mod.rs
+++ b/crates/oakengine/tests/common/mod.rs
@@ -25,7 +25,7 @@
//! would otherwise drop the dev-dependency rlibs from the link and
//! leave the imports undefined.
//!
-//! 2. **Provide the `oakcore_*` symbols** ([`oakcore_stubs`]) that the
+//! 2. **Provide the `oakcore_*` symbols** that the
//! oakcodec rlib references: `oakcore_audioparams_*` /
//! `oakcore_rational_*` live in the C++ liboakcore (only linked in the
//! real build), so cargo tests define minimal in-memory mocks — the
@@ -36,7 +36,7 @@
#![allow(dead_code, unused_variables)]
use std::collections::HashMap;
-use std::ffi::{c_char, c_int, c_void};
+use std::ffi::{c_int, c_void};
use std::sync::{Mutex, OnceLock};
/// Force the module crates into the link.
@@ -264,176 +264,3 @@ pub extern "C" fn oakcore_rational_free(rational: *mut c_void) {
// `(i32, i32)` pair; we hold the only reference after removal.
unsafe { drop(Box::from_raw(rational as *mut (i32, i32))) };
}
-
-// ---------------------------------------------------------------------------
-// ffmpeg_bridge (`fb_*`) stubs
-//
-// The oakaudio processor family drives the C++ libffmpeg_bridge audio
-// graph (`src/audio/rust/src/bridge/ffmpeg.rs`), which is not linked
-// under `cargo test`. These minimal mocks keep the link green; the
-// processor family's real behavior requires libffmpeg_bridge and its
-// tests are `#[ignore]`d with that reason.
-// ---------------------------------------------------------------------------
-
-/// Opaque audio graph handle.
-#[repr(C)]
-pub struct AudioGraph {
- _opaque: [u8; 0],
-}
-/// Opaque frame handle.
-#[repr(C)]
-pub struct Frame {
- _opaque: [u8; 0],
-}
-/// Opaque packet handle.
-#[repr(C)]
-pub struct Packet {
- _opaque: [u8; 0],
-}
-/// Opaque decoder handle.
-#[repr(C)]
-pub struct Decoder {
- _opaque: [u8; 0],
-}
-/// Opaque graph config.
-#[repr(C)]
-pub struct AudioGraphConfig {
- _opaque: [u8; 0],
-}
-/// Opaque stream-info out struct.
-#[repr(C)]
-pub struct FBStreamInfo {
- _opaque: [u8; 0],
-}
-
-#[no_mangle]
-pub extern "C" fn fb_audio_graph_create(_config: *const AudioGraphConfig) -> *mut AudioGraph {
- std::ptr::null_mut()
-}
-#[no_mangle]
-pub extern "C" fn fb_audio_graph_free(graph: *mut *mut AudioGraph) {
- if !graph.is_null() {
- unsafe { *graph = std::ptr::null_mut() };
- }
-}
-#[no_mangle]
-pub extern "C" fn fb_audio_graph_push(
- _graph: *mut AudioGraph,
- _channel_data: *const *const u8,
- _nb_samples: c_int,
-) -> c_int {
- -1
-}
-#[no_mangle]
-pub extern "C" fn fb_audio_graph_pull(_graph: *mut AudioGraph, _out_frame: *mut Frame) -> c_int {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_channel_layout_get_channels(_mask: u64) -> c_int {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_channel_layout_default(_nb_channels: c_int) -> u64 {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_alloc() -> *mut Frame {
- std::ptr::null_mut()
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_free(frame: *mut *mut Frame) {
- if !frame.is_null() {
- unsafe { *frame = std::ptr::null_mut() };
- }
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_unref(_frame: *mut Frame) {}
-#[no_mangle]
-pub extern "C" fn fb_frame_get_nb_samples(_frame: *const Frame) -> c_int {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_set_nb_samples(_frame: *mut Frame, _nb_samples: c_int) {}
-#[no_mangle]
-pub extern "C" fn fb_frame_get_sample_rate(_frame: *const Frame) -> c_int {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_get_format(_frame: *const Frame) -> c_int {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_get_channel_layout_mask(_frame: *const Frame) -> u64 {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_get_data(_frame: *mut Frame, _plane: c_int) -> *mut u8 {
- std::ptr::null_mut()
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_get_data_const(_frame: *const Frame, _plane: c_int) -> *const u8 {
- std::ptr::null()
-}
-#[no_mangle]
-pub extern "C" fn fb_frame_get_linesize(_frame: *const Frame, _plane: c_int) -> c_int {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_packet_alloc() -> *mut Packet {
- std::ptr::null_mut()
-}
-#[no_mangle]
-pub extern "C" fn fb_packet_free(packet: *mut *mut Packet) {
- if !packet.is_null() {
- unsafe { *packet = std::ptr::null_mut() };
- }
-}
-#[no_mangle]
-pub extern "C" fn fb_packet_unref(_packet: *mut Packet) {}
-#[no_mangle]
-pub extern "C" fn fb_decoder_create() -> *mut Decoder {
- std::ptr::null_mut()
-}
-#[no_mangle]
-pub extern "C" fn fb_decoder_free(decoder: *mut *mut Decoder) {
- if !decoder.is_null() {
- unsafe { *decoder = std::ptr::null_mut() };
- }
-}
-#[no_mangle]
-pub extern "C" fn fb_decoder_open(
- _decoder: *mut Decoder,
- _filename: *const c_char,
- _stream_index: c_int,
-) -> c_int {
- -1
-}
-#[no_mangle]
-pub extern "C" fn fb_decoder_close(_decoder: *mut Decoder) {}
-#[no_mangle]
-pub extern "C" fn fb_decoder_get_frame(
- _decoder: *mut Decoder,
- _packet: *mut Packet,
- _frame: *mut Frame,
-) -> c_int {
- -1
-}
-#[no_mangle]
-pub extern "C" fn fb_decoder_get_packet(_decoder: *mut Decoder, _packet: *mut Packet) -> c_int {
- -1
-}
-#[no_mangle]
-pub extern "C" fn fb_decoder_get_stream_info(
- _decoder: *const Decoder,
- _out: *mut FBStreamInfo,
-) -> c_int {
- -1
-}
-#[no_mangle]
-pub extern "C" fn fb_decoder_get_format_start_time(_decoder: *const Decoder) -> i64 {
- 0
-}
-#[no_mangle]
-pub extern "C" fn fb_decoder_get_format_duration(_decoder: *const Decoder) -> i64 {
- 0
-}
diff --git a/crates/oakengine/tests/it_audio.rs b/crates/oakengine/tests/it_audio.rs
index a3c2b22b0..6ada0b52b 100644
--- a/crates/oakengine/tests/it_audio.rs
+++ b/crates/oakengine/tests/it_audio.rs
@@ -38,16 +38,6 @@
//! values) must return a clean negative code or a documented no-op —
//! never crash/abort/panic.
//!
-//! ## Ignored with reason
-//!
-//! * `processor_full_open_convert_cycle`: opening the conversion graph
-//! requires the C++ host libffmpeg_bridge (`fb_audio_graph_*`), which
-//! is not linked under `cargo test` (tests/common/mod.rs provides
-//! no-op stubs); the module's open then fails cleanly at graph
-//! creation. The validation and failure paths run for real in the
-//! non-ignored tests; only the success path of a real graph is
-//! environment-bound.
-//!
//! Note: `start_recording` with an input device set drives the REAL
//! oakcodec FFmpeg encoder (ffmpeg-next), so it writes a real media file
//! to the system temp dir when the host has the codec; when the host
@@ -87,6 +77,8 @@ use oakengine::handle::{CHandle, OakEngineAudioProcessor};
const AUDIO_E_INVALID: c_int = -60001;
/// `OAKAUDIO_E_FAILED`.
const AUDIO_E_FAILED: c_int = -60003;
+/// `OAKAUDIO_E_STATE`.
+const AUDIO_E_STATE: c_int = -60002;
// ---------------------------------------------------------------------------
// Serialization + shared fixtures
@@ -1060,23 +1052,26 @@ fn processor_open_validation() {
common::oakcore_audioparams_free(from);
common::oakcore_audioparams_free(to_packed);
- // Legal arguments reach the module's graph creation, which needs the
- // host libffmpeg_bridge (not linked under cargo test): the open
- // reports OAKAUDIO_E_FAILED and the processor stays closed.
+ // Legal arguments reach the module's graph creation, which now runs
+ // a real in-process FFmpeg filter graph (ffmpeg-next): the open
+ // succeeds and the processor reports open; a second open is a state
+ // error and close shuts it down again.
let from = audio_params(48000, 3, 4);
let to = audio_params(48000, 3, 4);
assert_eq!(
unsafe { oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 1.0) },
- AUDIO_E_FAILED
+ 0
);
+ assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 1);
+ assert_eq!(
+ unsafe { oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 1.0) },
+ AUDIO_E_STATE
+ );
+ assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0);
assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 0);
common::oakcore_audioparams_free(from);
common::oakcore_audioparams_free(to);
- // A failed open left the processor closed: opening again is not
- // "already open".
- assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0);
-
unsafe { oakengine_audio_processor_free(p) };
});
}
@@ -1156,13 +1151,12 @@ fn processor_convert_and_output_params_stubs() {
});
}
-/// The full open→convert cycle requires the C++ host libffmpeg_bridge
-/// (fb_audio_graph_*) which is not linked under `cargo test` — the module
-/// open then fails at graph creation (OAKAUDIO_E_FAILED, asserted in
-/// [`processor_open_validation`]). This documents the intended success
-/// contract for a host-linked build.
+/// The full open→close cycle runs the module's real in-process FFmpeg
+/// filter graph (ffmpeg-next), so open succeeds under `cargo test`.
+/// `convert` stays the documented facade stub (`OAKENGINE_E_FAILED`; see
+/// [`processor_convert_and_output_params_stubs`]) — the module-level
+/// convert success path is covered by oakaudio's own processor tests.
#[test]
-#[ignore = "needs the C++ host libffmpeg_bridge (fb_audio_graph_*), not linked under cargo test"]
fn processor_full_open_convert_cycle() {
with_processor(|| {
let p = unsafe { oakengine_audio_processor_create() };
@@ -1177,6 +1171,7 @@ fn processor_full_open_convert_cycle() {
let mut in_planes: [*mut f32; 2] = [std::ptr::null_mut(); 2];
let mut out_data: *const c_void = std::ptr::null();
let mut out_size: c_int = 0;
+ // The facade convert is the documented "not backed" stub.
assert_eq!(
unsafe {
oakengine_audio_processor_convert(
@@ -1187,8 +1182,9 @@ fn processor_full_open_convert_cycle() {
&mut out_size,
)
},
- 0
+ OAKENGINE_E_FAILED
);
+ assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0);
common::oakcore_audioparams_free(from);
common::oakcore_audioparams_free(to);
unsafe { oakengine_audio_processor_free(p) };
diff --git a/crates/oakengine/tests/it_task.rs b/crates/oakengine/tests/it_task.rs
index 01050d73d..8869ebabf 100644
--- a/crates/oakengine/tests/it_task.rs
+++ b/crates/oakengine/tests/it_task.rs
@@ -20,7 +20,7 @@
//! Coverage rules (see the family test charter):
//! 1. no mocks — every call goes through the real facade into the real
//! oaktask/oaknode/oakundo/oakcodec module crates (the only stubs are
-//! the host-provided `oakcore_*`/`fb_*` symbols in `tests/common`,
+//! the host-provided `oakcore_*` symbols in `tests/common`,
//! the same mechanism the other family tests use);
//! 2. every one of the 27 `oakengine_task_*` / `oakengine_cli_task_*`
//! exports is exercised on a legal path with the result asserted;
@@ -603,9 +603,9 @@ fn import_flow_with_real_file() {
let root = unsafe { oakengine_project_root(project) };
assert!(!root.is_null());
- // A real file that cannot be decoded in the test environment (the
- // host-provided `fb_*` symbols are the common stubs; footage probing
- // never succeeds there, so a run would mark the file invalid).
+ // A real file that cannot be decoded in the test environment (footage
+ // probing of a non-media file never succeeds, so a run would mark the
+ // file invalid).
let media = std::env::temp_dir().join("oakengine_it_task_import_batch.tmp");
std::fs::write(&media, b"not media").unwrap();
let media_c = std::ffi::CString::new(media.to_str().unwrap()).unwrap();