refactor(oakaudio): drop ffmpeg_bridge fb_* imports, call ffmpeg-next in-process

- processor.rs: resample/channel-convert/time-stretch now runs an
  in-process FFmpeg filter graph (abuffer -> atempo -> aformat ->
  abuffersink) via ffmpeg-next
- waveform.rs: extraction decodes through oakcodec's FFmpegDecoder
  (interleaved f32) instead of the fb_decoder/fb_audio_graph pair
- bridge/ffmpeg.rs and the null fb_* test stubs deleted;
  oakcommon::ffmpegutils bridge constants become plain ints
- liboakengine.dylib no longer imports any fb_* symbol (nm -u clean);
  the remaining runtime imports are the host-provided oakcore_* symbols
- real decode/resample tests added (generated PCM input, no network)
This commit is contained in:
2026-08-11 16:10:14 +08:00
parent aa5fcef66e
commit 26009a5ea5
14 changed files with 488 additions and 1258 deletions
Generated
+1
View File
@@ -3924,6 +3924,7 @@ dependencies = [
name = "oakaudio"
version = "0.1.0"
dependencies = [
"ffmpeg-next",
"oakcodec",
"oakcommon",
"oakcore-rs",
+4
View File
@@ -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"
-216
View File
@@ -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 <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;
}
-1
View File
@@ -19,4 +19,3 @@
pub mod codec;
pub mod common;
pub mod ffmpeg;
+215 -96
View File
@@ -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<ffmpeg::filter::Graph>,
/// 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<ffmpeg::filter::Graph> {
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<bool> {
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<i32> {
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::<T>` 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::<f32>(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(())
}
+58 -144
View File
@@ -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<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.
/// Append one decoded chunk's interleaved f32 samples to the per-channel
/// `pending` planes.
fn append_pending(pending: &mut Vec<Vec<f32>>, 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::<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(),
));
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<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.
/// 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));
}
}
+57 -522
View File
@@ -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<Vec<f32>>,
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<Vec<u8>>,
}
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<u8>,
}
#[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<std::fs::File>,
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<u8> {
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,
+63 -18
View File
@@ -15,9 +15,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! 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) };
+37 -5
View File
@@ -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();
}
+19 -46
View File
@@ -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
+7 -6
View File
@@ -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).
+2 -175
View File
@@ -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
}
+21 -25
View File
@@ -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) };
+4 -4
View File
@@ -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();