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:
Generated
+1
@@ -3924,6 +3924,7 @@ dependencies = [
|
||||
name = "oakaudio"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ffmpeg-next",
|
||||
"oakcodec",
|
||||
"oakcommon",
|
||||
"oakcore-rs",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,4 +19,3 @@
|
||||
|
||||
pub mod codec;
|
||||
pub mod common;
|
||||
pub mod ffmpeg;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) };
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user