From 26009a5ea54a961df842389443124b20caa3639d Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 11 Aug 2026 16:10:14 +0800 Subject: [PATCH] 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) --- Cargo.lock | 1 + crates/oakaudio/Cargo.toml | 4 + crates/oakaudio/src/bridge/ffmpeg.rs | 216 --------- crates/oakaudio/src/bridge/mod.rs | 1 - crates/oakaudio/src/processor.rs | 311 +++++++++---- crates/oakaudio/src/waveform.rs | 202 +++------ crates/oakaudio/tests/common/mod.rs | 579 +++--------------------- crates/oakaudio/tests/processor_test.rs | 81 +++- crates/oakaudio/tests/waveform_test.rs | 42 +- crates/oakcommon/src/ffmpegutils.rs | 65 +-- crates/oakengine/Cargo.toml | 13 +- crates/oakengine/tests/common/mod.rs | 177 +------- crates/oakengine/tests/it_audio.rs | 46 +- crates/oakengine/tests/it_task.rs | 8 +- 14 files changed, 488 insertions(+), 1258 deletions(-) delete mode 100644 crates/oakaudio/src/bridge/ffmpeg.rs 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();