feat(rust): first green Rust crates — oakcore-rs, oakundo, oakcommon, oaktimeline, oakaudio

- oakcore-rs: Rational/TimeRange/TimeRangeList/PixelFormat/SampleFormat
  mirroring oakcore C++ semantics (91.6% coverage)
- oakundo: UndoCommand/UndoStack with vtable commands (99.6%)
- oakcommon: config/XML/VideoParams/logging etc. (89.8%, 516 tests);
  XML via quick-xml, logging via the log facade
- oaktimeline: markers/workarea/edit commands (95.4%)
- oakaudio: processor/manager/sync/waveform/levelmeter (88.4%)
- unified -MMCCCC error codes across all C ABI headers (registry in
  include/common/error.h; pass-through rule)
- olive::Variant moved node -> common (cross-module value type)
- oakcommon_videoparams_set_is_3d added to the C ABI
- M11 (oakplugin Rust rewrite) plan + notes.md freeze line for the
  inter-module C ABI wiring
This commit is contained in:
2026-08-09 04:24:28 +08:00
parent 5a564f30ca
commit 4b24aa9d67
138 changed files with 47853 additions and 48 deletions
+90
View File
@@ -0,0 +1,90 @@
# oakaudio 类覆盖映射表(C++ audio 模块 → oakaudio Rust crate)
> 逐类盘点 `src/audio/src` 与 `src/audio/c_api`。每一行标注 Rust 侧落点。
> `// CPP-PARITY` 注释义务不变:凡承载布局/数值/边角行为的地方,标出
> C++ 文件:行号。本表是初稿;实现阶段据实核对。
## 1. AudioManager(manager.rs)
| C++ | Rust 落点 |
|---|---|
| `AudioManager::create_instance` / `destroy_instance` / `instance` | `manager::create_instance` / `destroy_instance` / `instance`(`OnceLock<Mutex<ManagerInner>>` 单例) |
| `set_output_notify_interval` / `set_output_notify_callback` | `manager::set_output_notify_interval`(notify 回调经 guard 从 PortAudio 线程调用,见 previewdevice.rs) |
| `push_to_output` / `clear_buffered_output` / `stop_output` | `manager::push_to_output` / `clear_buffered_output` / `stop_output` |
| `seconds` / `reset_output_clock` | `manager::seconds` / `reset_output_clock`(播放时钟补偿输出延迟) |
| `get/set_output_device` / `get/set_input_device` | `manager` 设备访问器(PaDeviceIndex,-1 = paNoDevice) |
| `hard_reset` | `manager::hard_reset`(关流并重初始化 PortAudio) |
| `start_recording` / `stop_recording` | `manager` 录音(经 `bridge::codec` OakEncoder,恒定 interleaved f32) |
| `find_config_device_by_name` / `find_device_by_name`(static) | `manager::find_config_device_by_name_s` / `find_device_by_name_s` |
| `get_port_audio_params` / `get_port_audio_sample_format`(私有) | `manager` 内部(映射 AudioParams ↔ PortAudio;`// CPP-PARITY` 标注格式映射) |
## 2. AudioProcessor(processor.rs)
| C++ | Rust 落点 |
|---|---|
| `open(from,to,tempo)` / `close` / `is_open` | `processor::open` / `close` / `is_open`(FBAudioGraphConfig 组装) |
| `convert` | `processor::convert`(planar f32 进/出;`fb_audio_graph_push` + `fb_audio_graph_pull`) |
| `flush` | `processor::flush`(`fb_audio_graph_push` channel_data==NULL) |
| `from()` / `to()` | `processor` 保存的 `AudioParams` |
## 3. AudioSynchronizer(synchronizer.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `place_by_source_time` | `synchronizer::place_by_source_time` |
| `place_by_waveform_offset` | `synchronizer::place_by_waveform_offset` |
## 4. AudioLevelMeter(levelmeter.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `analyze_sample_buffer` | `levelmeter::analyze`(peak/RMS/VU 阈值与 `-200` 地板;`// CPP-PARITY` 标注) |
| `linear_to_db` / `power_to_lufs`(私有) | `levelmeter` 内部(BS.1770 LUFS,无 K-weighting) |
## 5. AudioVisualWaveform(waveform.rs)
| C++ | Rust 落点 |
|---|---|
| 构造 / `channel_count` / `set_channel_count` / `length` | `waveform::Waveform` + 访问器 |
| `overwrite_samples` | `waveform::overwrite_samples`(mipmap 展开,`k_minimum/maximum_sample_rate`) |
| `overwrite_sums` / `overwrite_silence` | `waveform::overwrite_sums` / `overwrite_silence` |
| `trim_in` / `mid` / `resize` / `trim_range` | `waveform::trim_in` / `mid` / `resize` / `trim_range` |
| `get_summary_from_time` | `waveform::get_summary` |
| `sum_samples` / `re_sum_samples`(static) | `waveform::sum_samples` / `re_sum_samples` |
| mipmap 内部(`overwrite_samples_from_*` / `get_mipmap_for_scale` / `time_to_samples` / `validate_virtual_start`) | `waveform` 内部 |
| 全文件提取(c_api `oakaudio_waveform_extract`) | `waveform::extract`(经 `bridge::codec` decoder + `bridge::ffmpeg`) |
| `SamplePerChannel` POD | 与 `oakaudio_min_max` `static_assert` 对齐(`// CPP-PARITY: c_api/waveform.cpp`) |
## 6. AudioWaveformSync(waveformsync.rs,纯静态)
| C++ | Rust 落点 |
|---|---|
| `extract_rms_envelope` | `waveformsync::extract_rms_envelope` |
| `estimate_offset`(SampleBuffer 版) | `waveformsync::estimate_offset`(内部先提取 envelope) |
| `estimate_envelope_offset`(两个重载) | `waveformsync::estimate_envelope_offset`(valid 掩码版为权威) |
| `estimate_stretch_and_offset` | `waveformsync::estimate_stretch_and_offset`(O(rates*lags*overlap)) |
## 7. PreviewAudioDevice(previewdevice.rs,header-only)
| C++ | Rust 落点 |
|---|---|
| `read` / `write` | `previewdevice::PreviewAudioDevice::read` / `write`(回调侧 pull) |
| `set_params` / `bytes_per_frame` / `set_bytes_per_frame` | `previewdevice`(AudioParams → bytes_per_frame) |
| `set_notify_interval` / `set_notify_callback` | `previewdevice`(notify 回调,锁外触发) |
| `clear` | `previewdevice::clear` |
| `add_output_frames` / `output_frames_consumed` / `reset_output_frames` | `previewdevice`(atomic 播放时钟) |
## 8. audio_config 命名空间(config.rs,不是类)
| C++ | Rust 落点 |
|---|---|
| `output_buffer_size()` | `config::output_buffer_size`(`oakcommon_config_get_int(nullptr,"AudioOutputBufferSize",0)`) |
| `device_name(is_output_device)` | `config::device_name`(`oakcommon_config_get` 两阶段;key = "AudioOutput"/"AudioInput") |
## 9. 刻意不迁移(drop)
| C++ | 理由 |
|---|---|
| `get_port_audio_params` 的 PortAudio 平台细节 | 归 `bridge::ffmpeg`/PortAudio 侧;Rust 保留语义与默认布局兜底 |
| Qt 常量(`qFuzzyIsNull` 等)内联展开 | 语义内联为 Rust 比较;`// CPP-PARITY` 标注 |
| `draw_sample`/`draw_waveform`(QPainter) | UI 绘制归 facade/app;crate 只存/汇总数据 |
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "oakaudio"
version = "0.1.0"
dependencies = [
"oakcore-rs",
]
[[package]]
name = "oakcore-rs"
version = "0.1.0"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "oakaudio"
version = "0.1.0"
edition = "2021"
description = "Oak Video Editor audio I/O, processing, synchronization and waveform engine (Rust)"
license = "GPL-3.0-or-later"
[lib]
crate-type = ["staticlib", "rlib"]
[profile.release]
# FFI discipline: panics must be catchable at every exported entry.
panic = "unwind"
[dependencies]
oakcore-rs = { path = "../../oakcore-rs" }
+102
View File
@@ -0,0 +1,102 @@
# oakaudio Rust crate
> Status: **implemented**. The C ABI (`include/audio/*.h`) is implemented
> by `src/ffi.rs`; the contract suite lives in `tests/` (all green,
> ~88% line coverage under tarpaulin). The architecture below mirrors the
> oaknode/oakrender crate template (FFI discipline, testing layers) from
> `src/node/rust/README.md` and `src/render/rust/README.md`.
## Scope
Replaces the C++ oakaudio module (`src/audio/src`, ~50k lines): the
PortAudio output/input manager (`AudioManager`), the real-time
resampler/format converter (`AudioProcessor`), timeline synchronization
helpers (`AudioSynchronizer`, `AudioWaveformSync`), the level meter
(`AudioLevelMeter`), the visual waveform store (`AudioVisualWaveform`),
the header-only pull buffer (`PreviewAudioDevice`), and the config
bridge (`audio_config` namespace).
Public contract: `include/audio/*.h` (5 headers plus `error.h`, ~45
functions) — frozen, implemented verbatim by `src/ffi.rs`.
## Key architectural decisions (C++ → Rust mapping)
1. **Singleton manager.** `AudioManager` is a process-wide PortAudio
singleton. Rust keeps the singleton behind a `OnceLock<Mutex<...>>`
with borrow-only handles: `addref`/`release` are no-ops exactly as on
the C++ side, and an empty handle reports `OAKAUDIO_E_STATE`. No
destruction ever happens through the handle.
2. **Processor is the only heavy FFI consumer.** `AudioProcessor` wraps
the ffmpeg_bridge audio filter graph (`fb_audio_graph_*`,
`fb_frame_*`); every call funnels through `bridge::ffmpeg`. The
resampler/format-conversion semantics and the always-planar-f32
output (`OAKAUDIO_PROCESSOR_OUTPUT_FORMAT = 4`) are preserved.
3. **Sync helpers are stateless.** `AudioSynchronizer` and
`AudioWaveformSync` have only static methods in C++; they become
plain functions in `synchronizer.rs` / `waveformsync.rs`. No handles
are involved on the sync headers except by-value arguments.
4. **Value types are local.** `params.rs` defines `AudioParams` (a
plain POD) and a **planar-first** `SampleFormat` enum mirroring
`olive::core::SampleFormat::Format` exactly, because these values
cross the C ABI as `int`. See the note in `params.rs` about why the
crate does not reuse `oakcore-rs`'s `SampleFormat`.
5. **Rational reuses oakcore-rs.** `core::Rational` (used by
synchronizer and waveform) comes from `oakcore-rs`; there is no
local copy.
6. **Record path through oakcodec.** `AudioManager` records through the
oakcodec encoder C ABI (`bridge::codec`) and waveform extraction
decodes through the oakcodec decoder C ABI — exactly as the C++
does. No direct ffmpeg_bridge use in the record path.
7. **Config via oakcommon.** Device names and the output buffer size
read through `bridge::common` (`oakcommon_config_*`), preserving the
`audio_config` namespace semantics as a `config.rs` free-function
module.
## Layout
`COVERAGE.md` maps every C++ audio class/method to its Rust home.
Review that first.
```
src/
lib.rs crate doc + module map
error.rs error codes (mirrors include/audio/error.h)
handle.rs refcounted-handle scaffolding (same pattern as node)
params.rs AudioParams + planar-first SampleFormat value types
config.rs audio_config namespace (bridge::common)
manager.rs AudioManager singleton (PortAudio I/O, recording)
processor.rs AudioProcessor (resampler/converter, bridge::ffmpeg)
synchronizer.rs AudioSynchronizer placement helpers
levelmeter.rs AudioLevelMeter peak/RMS/VU/LUFS analysis
waveform.rs AudioVisualWaveform mipmapped store + extraction
waveformsync.rs AudioWaveformSync envelope offset estimation
previewdevice.rs PreviewAudioDevice pull buffer
bridge/ C ABI imports: common.rs, codec.rs, ffmpeg.rs
ffi.rs include/audio/*.h export layer
tests/ contract + golden tests (see README test section)
```
## Hard rules for the implementer
1. Every `extern "C"` body goes through `handle::guard*`; no panic
crosses FFI. The manager's borrow-only singleton is the one place
`guard_handle`/`guard_void` are used with no refcount semantics.
2. `SampleFormat` and `AudioParams` integer values MUST match the C++
enums bit-for-bit; `// CPP-PARITY:` comments mark every load-bearing
layout decision.
3. Behavior parity with C++ is proven by the C ABI test-suite
(`src/audio/tests`, unchanged) plus the golden tests in `tests/`
(waveform mipmap/channel-interleaved layout, RMS/LUFS thresholds,
sync placement).
4. Where C++ behavior is genuinely load-bearing but ugly, port the
behavior, not the aesthetics; leave a `// CPP-PARITY:` comment with
the C++ file:line.
5. `src/plugin/` is frozen and out of scope; no oakaudio code reaches
into it.
## Dependency policy
Prefer mature third-party crates (MIT/Apache-2.0/BSD, GPL-compatible)
over hand-rolling; register each addition (name + reason) here. Large
existing C++ libraries (OTIO, OCIO, OIIO, FFmpeg) are NEVER rewritten
— they are consumed through their C ABI / bridge layers.
+183
View File
@@ -0,0 +1,183 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! oakcodec C ABI imports (audio encoding + decoding for the manager and
//! waveform extraction).
use std::ffi::{c_char, c_int, c_void};
/// `oakcodec_encoding_params` — a `#[repr(C)]` mirror of the same-named
/// struct in include/codec/encoder.h, field-for-field (the offset of every
/// audio field is load-bearing: `audio_enabled` sits at byte 1180 — NOT
/// 1028, which is `video_enabled` — because the 20-field video block
/// precedes it; natural alignment inserts 4 pad bytes before the first
/// `int64_t`, verified against `offsetof` on the C++ header).
///
/// Only the audio fields are consumed by oakaudio; the remaining fields
/// are kept verbatim to preserve layout.
///
/// `// CPP-PARITY: include/codec/encoder.h:79` (`oakcodec_encoding_params`).
#[repr(C)]
pub struct EncodingParams {
/// Output filename (NUL-terminated).
pub filename: [c_char; 1024],
/// Output container format id.
pub format: c_int,
/// Whether video is enabled.
pub video_enabled: c_int,
/// Video codec id.
pub video_codec: c_int,
/// Video width in pixels.
pub video_width: c_int,
/// Video height in pixels.
pub video_height: c_int,
/// Frame duration numerator.
pub video_time_base_num: c_int,
/// Frame duration denominator.
pub video_time_base_den: c_int,
/// Delivery pixel format (`OakPixelFormat`).
pub video_pixel_format: c_int,
/// Interlacing mode (`OAKCODEC_INTERLACE_*`).
pub video_interlacing: c_int,
/// Pixel aspect ratio numerator.
pub video_pixel_aspect_num: c_int,
/// Pixel aspect ratio denominator.
pub video_pixel_aspect_den: c_int,
/// Video bit rate in bits per second (0 = codec default).
pub video_bit_rate: i64,
/// Minimum video bit rate.
pub video_min_bit_rate: i64,
/// Maximum video bit rate.
pub video_max_bit_rate: i64,
/// Video buffer size in bytes.
pub video_buffer_size: i64,
/// Encoder threads (0 = auto).
pub video_threads: c_int,
/// Encoded pixel format name ("yuv420p", NUL-terminated).
pub video_pix_fmt: [c_char; 64],
/// Whether video is an image sequence (1/0).
pub video_is_image_sequence: c_int,
/// Scaling method (`OAKCODEC_ENCODING_SCALING_*`).
pub video_scaling_method: c_int,
/// Whether audio is enabled.
pub audio_enabled: c_int,
/// Audio codec id.
pub audio_codec: c_int,
/// Audio sample rate in Hz.
pub audio_sample_rate: c_int,
/// Audio channel layout mask.
pub audio_channel_layout: u64,
/// Audio sample format (`oakcodec` sample format).
pub audio_sample_format: c_int,
/// Audio bit rate in bits per second.
pub audio_bit_rate: i64,
/// Whether subtitles are enabled.
pub subtitles_enabled: c_int,
/// Subtitle codec id.
pub subtitles_codec: c_int,
/// Whether subtitles are a sidecar file.
pub subtitles_are_sidecar: c_int,
/// Sidecar subtitle format (`ExportFormat::Format`).
pub subtitles_sidecar_format: c_int,
/// Output OCIO colorspace name (empty = reference space).
pub color_transform_output: [c_char; 256],
/// Export length in seconds (rational), numerator.
pub export_length_num: c_int,
/// Export length in seconds (rational), denominator.
pub export_length_den: c_int,
/// Whether a custom export range is set.
pub has_custom_range: c_int,
/// Custom range in point numerator (seconds).
pub custom_range_in_num: i64,
/// Custom range in point denominator (seconds).
pub custom_range_in_den: i64,
/// Custom range out point numerator (seconds).
pub custom_range_out_num: i64,
/// Custom range out point denominator (seconds).
pub custom_range_out_den: i64,
}
/// `oakcodec_audio_stream_info` — audio stream metadata from probing.
///
/// `// CPP-PARITY: include/codec/decoder.h` (`oakcodec_audio_stream_info`).
#[repr(C)]
pub struct AudioStreamInfo {
/// Stream index.
pub stream_index: c_int,
/// Sample rate in Hz.
pub sample_rate: c_int,
/// Channel layout mask.
pub channel_layout: u64,
/// Number of channels.
pub channel_count: c_int,
/// Duration in stream time base units.
pub duration_ts: i64,
/// Stream time base numerator.
pub time_base_num: c_int,
/// Stream time base denominator.
pub time_base_den: c_int,
}
extern "C" {
/// `oakcodec_encoder_init` — create an encoder for `params`.
pub fn oakcodec_encoder_init(params: *const EncodingParams) -> *mut c_void;
/// `oakcodec_encoder_free`.
pub fn oakcodec_encoder_free(encoder: *mut c_void);
/// `oakcodec_encoder_open`.
pub fn oakcodec_encoder_open(encoder: *mut c_void) -> c_int;
/// `oakcodec_encoder_write_audio` — feed interleaved `f32` audio.
pub fn oakcodec_encoder_write_audio(
encoder: *mut c_void,
samples: *const f32,
frame_count: c_int,
) -> c_int;
/// `oakcodec_encoder_flush`.
pub fn oakcodec_encoder_flush(encoder: *mut c_void) -> c_int;
/// `oakcodec_encoder_last_error` — copy the last error string into `buf`.
pub fn oakcodec_encoder_last_error(
encoder: *mut c_void,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakcodec_decoder_probe` — create a probe handle for a file.
pub fn oakcodec_decoder_probe(filename: *const c_char) -> *mut c_void;
/// `oakcodec_decoder_free`.
pub fn oakcodec_decoder_free(decoder: *mut c_void);
/// `oakcodec_decoder_probe_audio_stream_count`.
pub fn oakcodec_decoder_probe_audio_stream_count(decoder: *mut c_void) -> c_int;
/// `oakcodec_decoder_probe_get_audio_stream` — copy audio stream info.
pub fn oakcodec_decoder_probe_get_audio_stream(
decoder: *mut c_void,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int;
/// `oakcodec_decoder_open` — open stream `stream_index` for decoding.
pub fn oakcodec_decoder_open(decoder: *mut c_void, filename: *const c_char, stream_index: c_int)
-> c_int;
/// `oakcodec_decoder_decode_audio` — decode/convert frames into `buf`.
pub fn oakcodec_decoder_decode_audio(
decoder: *mut c_void,
in_num: c_int,
in_den: c_int,
out_num: c_int,
out_den: c_int,
sample_rate: c_int,
channel_layout: u64,
buf: *mut f32,
buf_frames: c_int,
) -> c_int;
}
+50
View File
@@ -0,0 +1,50 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! oakcommon C ABI imports (config access + ffmpeg format conversion).
use std::ffi::{c_char, c_int};
// `oakcommon_config_get` — copy a config string value into `buf`.
extern "C" {
/// `oakcommon_config_get` — copy a config string value into `buf`.
pub fn oakcommon_config_get(
group: *const c_char,
key: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
}
// `oakcommon_config_get_int` — read an integer config value with a default.
extern "C" {
/// `oakcommon_config_get_int` — read an integer config value with a
/// default.
pub fn oakcommon_config_get_int(
group: *const c_char,
key: *const c_char,
default: c_int,
) -> c_int;
}
extern "C" {
/// `oakcommon_ffmpegutils_get_ffmpeg_sample_format` — map an ffmpeg
/// sample format enum to the oak core format, or the reverse.
pub fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(
smp_fmt: c_int,
out: *mut c_int,
) -> c_int;
}
+216
View File
@@ -0,0 +1,216 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! ffmpeg_bridge C ABI imports (audio filter graph, frames, decoder). The
//! graph converts/resamples/time-stretches planar audio; used by the
//! [`crate::processor`] resampler and the [`crate::waveform`] extractor.
use std::ffi::{c_char, c_int, c_void};
/// `FBSampleFormat` — mirrors `AVSampleFormat` (values cross the C ABI as
/// `int`). `fltp` (planar f32) is the natural exchange format for oakaudio.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:117`.
#[repr(i32)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SampleFormat {
/// No format.
None = -1,
/// Unsigned 8-bit, packed.
U8 = 0,
/// Signed 16-bit, packed.
S16 = 1,
/// Signed 32-bit, packed.
S32 = 2,
/// 32-bit float, packed.
Flt = 3,
/// 64-bit float, packed.
Dbl = 4,
/// Unsigned 8-bit, planar.
U8Planar = 5,
/// Signed 16-bit, planar.
S16Planar = 6,
/// Signed 32-bit, planar.
S32Planar = 7,
/// 32-bit float, planar.
Fltp = 8,
/// 64-bit float, planar.
Dblp = 9,
/// Signed 64-bit, packed.
S64 = 10,
/// Signed 64-bit, planar.
S64Planar = 11,
}
/// `FBAudioGraphConfig` — source graph input/output spec.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:510`.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct AudioGraphConfig {
/// Input sample rate in Hz.
pub in_sample_rate: c_int,
/// Input channel layout mask (`0` = derive from `in_channels`).
pub in_channel_layout_mask: u64,
/// Input sample format (`FBSampleFormat`; planar float in).
pub in_sample_format: c_int,
/// Input channel count.
pub in_channels: c_int,
/// Output sample rate in Hz.
pub out_sample_rate: c_int,
/// Output channel layout mask (`0` = derive from `out_channels`).
pub out_channel_layout_mask: u64,
/// Output sample format (`FBSampleFormat`).
pub out_sample_format: c_int,
/// Output channel count.
pub out_channels: c_int,
/// Whether the output is planar.
pub out_is_planar: c_int,
/// Time-stretch tempo multiplier.
pub tempo: f64,
}
/// Opaque audio filter graph.
pub type AudioGraph = c_void;
/// Opaque frame.
pub type Frame = c_void;
/// Opaque packet.
pub type Packet = c_void;
/// Opaque decoder.
pub type Decoder = c_void;
/// `FBStreamInfo` — decoded stream metadata, mirroring the same-named
/// struct in ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h. Only the
/// audio fields are consumed by oakaudio; the video/container fields are
/// kept to preserve layout.
///
/// `// CPP-PARITY: ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h:335`.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct FBStreamInfo {
/// Stream index.
pub index: c_int,
/// Media type (`FBMediaType`).
pub codec_type: c_int,
/// Opaque FFmpeg codec id.
pub codec_id: c_int,
/// Non-zero if a decoder exists for this stream.
pub has_decoder: c_int,
/// Video width.
pub width: c_int,
/// Video height.
pub height: c_int,
/// Video pixel format (`FBPixelFormat`).
pub pixel_format: c_int,
/// Video field order (`FBFieldOrder`).
pub field_order: c_int,
/// Video color range (`FBColorRange`).
pub color_range: c_int,
/// Raw `AVColorPrimaries` value.
pub color_primaries: c_int,
/// Raw `AVColorTransferCharacteristic` value.
pub color_trc: c_int,
/// Sample rate in Hz.
pub sample_rate: c_int,
/// Sample format (`FBSampleFormat`).
pub sample_format: c_int,
/// Channel layout mask (never zero for valid audio).
pub channel_layout_mask: u64,
/// Stream start time.
pub start_time: i64,
/// Stream duration.
pub duration: i64,
/// Stream time base numerator.
pub time_base_num: c_int,
/// Stream time base denominator.
pub time_base_den: c_int,
/// Average frame rate numerator.
pub avg_frame_rate_num: c_int,
/// Average frame rate denominator.
pub avg_frame_rate_den: c_int,
}
extern "C" {
/// `fb_audio_graph_create` — build a graph from `config`.
pub fn fb_audio_graph_create(config: *const AudioGraphConfig) -> *mut AudioGraph;
/// `fb_audio_graph_free`.
pub fn fb_audio_graph_free(graph: *mut *mut AudioGraph);
/// `fb_audio_graph_push` — push planar samples; `channel_data == NULL`
/// flushes the graph.
pub fn fb_audio_graph_push(
graph: *mut AudioGraph,
channel_data: *const *const u8,
nb_samples: c_int,
) -> c_int;
/// `fb_audio_graph_pull` — pull converted samples. 1 = frame produced,
/// 0 = need more input, negative = error.
pub fn fb_audio_graph_pull(graph: *mut AudioGraph, out_frame: *mut Frame) -> c_int;
/// `fb_channel_layout_get_channels` — channel count of a mask.
pub fn fb_channel_layout_get_channels(mask: u64) -> c_int;
/// `fb_channel_layout_default` — default layout mask for `nb_channels`.
pub fn fb_channel_layout_default(nb_channels: c_int) -> u64;
/// `fb_frame_alloc`.
pub fn fb_frame_alloc() -> *mut Frame;
/// `fb_frame_free`.
pub fn fb_frame_free(frame: *mut *mut Frame);
/// `fb_frame_unref`.
pub fn fb_frame_unref(frame: *mut Frame);
/// `fb_frame_get_nb_samples`.
pub fn fb_frame_get_nb_samples(frame: *const Frame) -> c_int;
/// `fb_frame_set_nb_samples`.
pub fn fb_frame_set_nb_samples(frame: *mut Frame, nb_samples: c_int);
/// `fb_frame_get_sample_rate`.
pub fn fb_frame_get_sample_rate(frame: *const Frame) -> c_int;
/// `fb_frame_get_format`.
pub fn fb_frame_get_format(frame: *const Frame) -> c_int;
/// `fb_frame_get_channel_layout_mask`.
pub fn fb_frame_get_channel_layout_mask(frame: *const Frame) -> u64;
/// `fb_frame_get_data` — writable plane data.
pub fn fb_frame_get_data(frame: *mut Frame, plane: c_int) -> *mut u8;
/// `fb_frame_get_data_const` — read-only plane data.
pub fn fb_frame_get_data_const(frame: *const Frame, plane: c_int) -> *const u8;
/// `fb_frame_get_linesize`.
pub fn fb_frame_get_linesize(frame: *const Frame, plane: c_int) -> c_int;
/// `fb_packet_alloc`.
pub fn fb_packet_alloc() -> *mut Packet;
/// `fb_packet_free`.
pub fn fb_packet_free(packet: *mut *mut Packet);
/// `fb_packet_unref`.
pub fn fb_packet_unref(packet: *mut Packet);
/// `fb_decoder_create`.
pub fn fb_decoder_create() -> *mut Decoder;
/// `fb_decoder_free`.
pub fn fb_decoder_free(decoder: *mut *mut Decoder);
/// `fb_decoder_open` — open stream `stream_index` of `filename`.
pub fn fb_decoder_open(decoder: *mut Decoder, filename: *const c_char, stream_index: c_int)
-> c_int;
/// `fb_decoder_close`.
pub fn fb_decoder_close(decoder: *mut Decoder);
/// `fb_decoder_get_frame` — decode one frame from `packet`.
pub fn fb_decoder_get_frame(decoder: *mut Decoder, packet: *mut Packet, frame: *mut Frame) -> c_int;
/// `fb_decoder_get_packet` — read one packet.
pub fn fb_decoder_get_packet(decoder: *mut Decoder, packet: *mut Packet) -> c_int;
/// `fb_decoder_get_stream_info` — copy stream info into `out`.
pub fn fb_decoder_get_stream_info(decoder: *const Decoder, out: *mut FBStreamInfo) -> c_int;
/// `fb_decoder_get_format_start_time`.
pub fn fb_decoder_get_format_start_time(decoder: *const Decoder) -> i64;
/// `fb_decoder_get_format_duration`.
pub fn fb_decoder_get_format_duration(decoder: *const Decoder) -> i64;
}
+22
View File
@@ -0,0 +1,22 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! C ABI imports from other oak modules (signatures mirror the public
//! headers verbatim; resolved at link time).
pub mod codec;
pub mod common;
pub mod ffmpeg;
+73
View File
@@ -0,0 +1,73 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The `audio_config` namespace from `src/audio/src/configbridge.*`:
//! audio-specific configuration read through the oakcommon C ABI.
use std::ffi::CString;
/// PortAudio output buffer size in frames; 0 = let PortAudio choose.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:30`
/// (`audio_config::output_buffer_size`).
pub fn output_buffer_size() -> i32 {
unsafe {
crate::bridge::common::oakcommon_config_get_int(
std::ptr::null(),
c"AudioOutputBufferSize".as_ptr(),
0,
)
}
}
/// Name of the configured audio device for `is_output_device`
/// (key "AudioOutput" / "AudioInput"); empty when absent.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:36`
/// (`audio_config::device_name`): two-stage size query; `size <= 1`
/// (absent or empty string) yields the empty string.
pub fn device_name(is_output_device: bool) -> CString {
let key = if is_output_device {
c"AudioOutput"
} else {
c"AudioInput"
};
unsafe {
let size = crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
std::ptr::null_mut(),
0,
);
if size <= 1 {
// Absent (OAKCOMMON_E_NOT_FOUND) or empty
return CString::default();
}
let mut buf = vec![0u8; size as usize];
if crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
buf.as_mut_ptr() as *mut std::ffi::c_char,
size,
) < 0
{
return CString::default();
}
// The buffer is NUL-terminated by the callee.
CString::from_vec_with_nul(buf)
.unwrap_or_else(|e| CString::new(e.into_bytes()).unwrap_or_default())
}
}
+64
View File
@@ -0,0 +1,64 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Error codes, mirroring `include/audio/error.h` verbatim; project-wide
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through
//! untranslated. Audio module number is 06, so codes are -60001..-60005.
//! Unlike codec/task there is no CANCELLED code.
/// Success.
pub const OAKAUDIO_OK: i32 = 0;
/// Null handle or invalid argument.
pub const OAKAUDIO_E_INVALID: i32 = -60001;
/// Call not valid in the current state.
pub const OAKAUDIO_E_STATE: i32 = -60002;
/// The underlying operation failed.
pub const OAKAUDIO_E_FAILED: i32 = -60003;
/// Index out of range / entry not found.
pub const OAKAUDIO_E_NOT_FOUND: i32 = -60004;
/// Allocation failed.
pub const OAKAUDIO_E_NOMEM: i32 = -60005;
/// Crate-internal result type; the FFI layer maps it to the codes.
pub type Result<T> = std::result::Result<T, Error>;
/// Crate-internal error.
#[derive(Debug)]
pub enum Error {
/// Null handle or invalid argument.
Invalid,
/// Wrong state.
State,
/// Operation failed (context string is log-only).
Failed(String),
/// Not found.
NotFound,
/// Out of memory.
NoMem,
}
impl Error {
/// Map to the public error code.
pub fn code(&self) -> i32 {
match self {
Error::Invalid => OAKAUDIO_E_INVALID,
Error::State => OAKAUDIO_E_STATE,
Error::Failed(_) => OAKAUDIO_E_FAILED,
Error::NotFound => OAKAUDIO_E_NOT_FOUND,
Error::NoMem => OAKAUDIO_E_NOMEM,
}
}
}
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Refcounted-handle scaffolding. Same pattern as the oaknode crate
//! (`src/node/rust/src/handle.rs`); intentionally duplicated rather
//! than shared — each module DLL must run its own addref/release code
//! (the function pointers in a handle always point into the DLL that
//! created the object).
//!
//! The `AudioManager` is the one exception: it uses the same `CHandle`
//! layout but with singleton semantics (addref/release no-ops). See
//! `manager.rs` and `include/audio/manager.h`.
//!
//! `// CPP-PARITY: src/audio/c_api/refcounted.h` (RefCounted box,
//! make_handle_in_place, free_handle) and `src/audio/c_api/alive.cpp`
//! (alive ledger behind `oakaudio_debug_alive_count`).
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};
use crate::error::{Error, OAKAUDIO_E_FAILED, OAKAUDIO_OK};
/// ABI version stamped into every handle.
pub const OAKAUDIO_ABI_VERSION: u32 = 1;
/// Live-object ledger behind `oakaudio_debug_alive_count`.
///
/// `// CPP-PARITY: src/audio/c_api/alive.cpp` (`g_alive`).
static ALIVE: AtomicI32 = AtomicI32::new(0);
/// Current number of live refcounted oakaudio objects.
pub fn alive_count() -> i32 {
ALIVE.load(Ordering::Relaxed)
}
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// `#[repr(C)]` mirror of the public handle structs
/// (`{ctx, addref, release, abi_version}`).
///
/// Handles cross the C ABI by value (the C caller copies the 4-field
/// struct), so the type is `Copy`/`Clone` — the oaknode crate only derives
/// `Clone`, but `Copy` matches the C semantics exactly and lets exports and
/// tests pass the same handle around freely. No `Drop`: releasing goes
/// through the explicit `free_*`/`release` path, never on scope exit.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CHandle {
/// Opaque box pointer.
pub ctx: *mut std::ffi::c_void,
/// Atomic increment.
pub addref: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// Atomic decrement; destroys at zero.
pub release: Option<unsafe extern "C" fn(*mut std::ffi::c_void)>,
/// ABI version.
pub abi_version: u32,
}
// Handles are passed by value across the C ABI and used from the caller's
// thread; the objects behind them synchronize their own state.
unsafe impl Send for CHandle {}
impl CHandle {
/// The empty handle.
pub fn null() -> Self {
CHandle {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// True when the handle carries no object.
pub fn is_null(&self) -> bool {
self.ctx.is_null()
}
}
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_addref`).
unsafe extern "C" fn owned_addref<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a `RefBox<T>` created by
// `make_owned`; we only touch it through the reference while it is live.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
b.refs.fetch_add(1, Ordering::Relaxed);
}
}
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_release`):
/// destroys the box at zero and decrements the alive ledger.
unsafe extern "C" fn owned_release<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a live `RefBox<T>` created
// by `make_owned`; the refcount guards against double-free, and the box
// is only reclaimed once the count reaches zero.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
if b.refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(unsafe { Box::from_raw(ctx as *mut RefBox<T>) });
ALIVE.fetch_sub(1, Ordering::Relaxed);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
let b = Box::new(RefBox {
refs: AtomicU32::new(1),
value,
});
ALIVE.fetch_add(1, Ordering::Relaxed);
CHandle {
ctx: Box::into_raw(b) as *mut std::ffi::c_void,
addref: Some(owned_addref::<T>),
release: Some(owned_release::<T>),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// No-op addref/release for singleton (borrowed) handles.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`singleton_addref` /
/// `singleton_release`).
unsafe extern "C" fn noop_ref(_ctx: *mut std::ffi::c_void) {}
/// Borrowed handle for an object owned elsewhere (addref/release are
/// no-ops; nothing is ever freed through the handle).
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle.
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
CHandle {
ctx: ptr as *mut std::ffi::c_void,
addref: Some(noop_ref),
release: Some(noop_ref),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// Typed view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow.
let v = unsafe { &(*(h.ctx as *const RefBox<T>)).value };
Some(v)
}
/// Typed mutable view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get_mut<T: 'static>(h: &CHandle) -> Option<&mut T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow,
// and the caller must not alias it with other live borrows.
let v = unsafe { &mut (*(h.ctx as *mut RefBox<T>)).value };
Some(v)
}
/// Shared free() body: release the ctx, no-op on NULL/empty handle.
///
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`free_handle`).
///
/// # Safety
/// `self_` must be a valid handle pointer or NULL.
pub unsafe fn free_handle(h: *mut CHandle) {
// SAFETY: caller guarantees `h` is a valid handle pointer or NULL.
if let Some(r) = unsafe { h.as_mut() } {
if !r.ctx.is_null() {
if let Some(release) = r.release {
// SAFETY: the release fn belongs to the same DLL and
// accepts the ctx it originally created.
unsafe { release(r.ctx) };
}
r.ctx = std::ptr::null_mut();
}
}
}
/// Panic-catching FFI wrapper for i32-returning exports.
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => OAKAUDIO_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for i32 value-returning exports (errors map
/// to the negative error code).
pub fn guard_int<F: FnOnce() -> crate::error::Result<i32>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
/// Copy a human-readable error string into a C buffer (NUL-terminated,
/// truncated to fit). Returns the required size including the NUL.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`write_error`).
pub fn write_error(s: &str, buf: *mut std::ffi::c_char, buf_size: i32) {
if !buf.is_null() && buf_size > 0 {
let bytes = s.as_bytes();
let n = bytes.len().min((buf_size - 1) as usize);
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, n);
*(buf as *mut u8).add(n) = 0;
}
}
}
/// Convenience: map a condition to an Invalid error.
pub fn invalid_if(cond: bool) -> crate::error::Result<()> {
if cond {
Err(Error::Invalid)
} else {
Ok(())
}
}
+156
View File
@@ -0,0 +1,156 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Loudness analysis (`olive::AudioLevelMeter`). A static helper producing
//! per-channel peak/RMS/VU statistics plus an overall LUFS-integrated
//! summary. Pure function module in Rust; feeds the level-meter UI widget.
/// dB floor for all decibel readings.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:30`
/// (`k_decibel_minimum`, inlined from engine/common/decibel.h).
const DECIBEL_MINIMUM: f64 = -200.0;
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:32`
/// (`decibel_from_linear`): -inf clamps to the floor.
fn decibel_from_linear(linear: f64) -> f64 {
let v = 20.0 * linear.log10();
if v.is_infinite() {
return DECIBEL_MINIMUM;
}
v
}
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:99`
/// (`AudioLevelMeter::linear_to_db`).
fn linear_to_db(linear: f64) -> f64 {
if linear <= 0.0 {
return DECIBEL_MINIMUM;
}
decibel_from_linear(linear)
}
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:107`
/// (`AudioLevelMeter::power_to_lufs`): BS.1770-compatible unit, no
/// K-weighting.
fn power_to_lufs(mean_square: f64) -> f64 {
if mean_square <= 0.0 {
return DECIBEL_MINIMUM;
}
-0.691 + 10.0 * mean_square.log10()
}
/// Per-channel statistics for a single analysis pass.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.h`
/// (`AudioLevelMeter::ChannelStats`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ChannelStats {
/// Peak amplitude, linear scale. `0.0` when silent.
pub peak_linear: f64,
/// Peak amplitude, decibel scale. `-200.0` when silent.
pub peak_db: f64,
/// Root-mean-square level, linear scale.
pub rms_linear: f64,
/// Root-mean-square level, decibel scale. `-200.0` when silent.
pub rms_db: f64,
/// VU-meter ballistics reading, decibel scale.
pub vu_db: f64,
}
/// Aggregate statistics over all analyzed channels.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.h`
/// (`AudioLevelMeter::Stats`).
#[derive(Debug, Clone, PartialEq)]
pub struct Stats {
/// Per-channel statistics, indexed by channel.
pub channels: Vec<ChannelStats>,
/// Maximum peak across all channels, linear scale.
pub max_peak_linear: f64,
/// Integrated loudness (EBU R128 LUFS). `-200.0` for silence.
pub integrated_lufs: f64,
/// Whether every channel was silent below the noise gate.
pub silence: bool,
}
/// Compute per-channel and summary statistics for a planar sample buffer.
///
/// `planar` holds one f32 slice per channel; every channel slice is assumed to
/// have the same length. Mirrors `AudioLevelMeter::analyze_sample_buffer`.
///
/// `// CPP-PARITY: src/audio/src/audiolevelmeter.cpp:42`
/// (`AudioLevelMeter::analyze_sample_buffer`): VU == RMS dB (no separate
/// ballistics); the silence gate is `qFuzzyIsNull` (|x| < 1e-12) on the
/// max peak; LUFS uses the mean square over ALL channels' samples.
pub fn analyze_sample_buffer(planar: &[&[f32]]) -> Stats {
let mut stats = Stats {
channels: vec![
ChannelStats {
peak_linear: 0.0,
peak_db: DECIBEL_MINIMUM,
rms_linear: 0.0,
rms_db: DECIBEL_MINIMUM,
vu_db: DECIBEL_MINIMUM,
};
planar.len()
],
max_peak_linear: 0.0,
integrated_lufs: DECIBEL_MINIMUM,
silence: true,
};
if planar.is_empty() || planar[0].is_empty() {
return stats;
}
let sample_count = planar[0].len();
let mut total_square = 0.0f64;
let mut total_samples = 0usize;
for (channel, data) in planar.iter().enumerate() {
let mut peak = 0.0f64;
let mut square_sum = 0.0f64;
for &s in data.iter() {
let value = f64::from(s);
peak = peak.max(value.abs());
square_sum += value * value;
}
let mean_square = square_sum / sample_count as f64;
let rms = mean_square.sqrt();
let rms_db = linear_to_db(rms);
stats.channels[channel] = ChannelStats {
peak_linear: peak,
peak_db: linear_to_db(peak),
rms_linear: rms,
rms_db,
vu_db: rms_db,
};
stats.max_peak_linear = stats.max_peak_linear.max(peak);
total_square += square_sum;
total_samples += sample_count;
}
// qFuzzyIsNull(double): |x| < 1e-12
stats.silence = stats.max_peak_linear.abs() < 1e-12;
stats.integrated_lufs = power_to_lufs(total_square / total_samples as f64);
stats
}
+45
View File
@@ -0,0 +1,45 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! # oakaudio — the audio I/O, processing and synchronization engine (Rust)
//!
//! Reimplements the C++ oakaudio module behind its frozen C ABI
//! (`include/audio/*.h`). See README.md for the architectural mapping
//! (singleton manager, stateless sync helpers, local value types).
//!
//! ## FFI discipline
//!
//! Identical to the oaknode crate: every export goes through
//! [`handle::guard*`], handles are opaque refcounted boxes (or, for the
//! singleton `AudioManager`, borrow-only no-ops), shared state behind
//! `Mutex`.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod bridge;
pub mod config;
pub mod error;
pub mod ffi;
pub mod handle;
pub mod levelmeter;
pub mod manager;
pub mod params;
pub mod previewdevice;
pub mod processor;
pub mod synchronizer;
pub mod waveform;
pub mod waveformsync;
+362
View File
@@ -0,0 +1,362 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The process-wide PortAudio output/input manager (`olive::AudioManager`).
//!
//! Singleton semantics: the single instance lives behind a
//! `OnceLock<Mutex<ManagerInner>>`; handles returned to C are borrowed and
//! their addref/release are no-ops (mirrors the C++ singleton and
//! `include/audio/manager.h`). An empty handle reports `OAKAUDIO_E_STATE`.
//!
//! Recording goes through the oakcodec encoder C ABI ([`crate::bridge`]);
//! device/config lookups go through oakcommon.
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
use crate::bridge::codec::EncodingParams;
use crate::error::{Error, Result};
use crate::handle::{make_borrowed, CHandle};
use crate::params::AudioParams;
use crate::previewdevice::PreviewAudioDevice;
/// `paNoDevice` (PortAudio "no device" sentinel; also the default when no
/// device is configured).
const PA_NO_DEVICE: i32 = -1;
/// The process-wide manager state. `OnceLock` cannot be reset, so
/// [`destroy_instance`] flips `DESTROYED` to make [`instance`] hand out empty
/// handles again (the singleton box itself is retained).
static MANAGER: OnceLock<Mutex<ManagerInner>> = OnceLock::new();
static DESTROYED: AtomicBool = AtomicBool::new(false);
/// Manager state (all device/stream fields; PortAudio itself is not bridged,
/// see [`ManagerInner::default`] for the degradations).
struct ManagerInner {
/// Current output device index.
output_device: i32,
/// Current input device index.
input_device: i32,
/// Output params the buffer is configured for.
output_params: Option<AudioParams>,
/// Queued output samples feeding the (virtual) playback clock.
output_buffer: PreviewAudioDevice,
/// Whether the output "stream" is running (stand-in for
/// `Pa_IsStreamActive`).
output_started: bool,
/// Active oakcodec recording encoder (NULL when idle).
recording: Option<*mut c_void>,
}
// SAFETY: the raw encoder pointer is only touched while the manager mutex is
// held, which serializes every access; the encoder lives until `recording` is
// taken out in `stop_recording`.
unsafe impl Send for ManagerInner {}
impl Default for ManagerInner {
fn default() -> Self {
ManagerInner {
// CPP-PARITY: no device is selected until `create_instance` runs
// the config lookup (PortAudio enumeration cannot be bridged, so
// the devices stay at paNoDevice and the C layer reports E_FAILED
// on output/recording until a device is set explicitly).
output_device: PA_NO_DEVICE,
input_device: PA_NO_DEVICE,
output_params: None,
output_buffer: PreviewAudioDevice::new(),
output_started: false,
recording: None,
}
}
}
/// Lock the manager behind a handle; `OAKAUDIO_E_STATE` for empty handles.
fn with_instance(h: &CHandle) -> Result<MutexGuard<'static, ManagerInner>> {
if h.is_null() {
return Err(Error::State);
}
// SAFETY: `instance()` only creates borrowed handles whose ctx points at
// the MANAGER Mutex, which lives in a static for the whole process.
let m: &'static Mutex<ManagerInner> =
unsafe { &*(h.ctx as *const Mutex<ManagerInner>) };
Ok(m.lock().unwrap_or_else(|p| p.into_inner()))
}
/// Create the process-wide AudioManager (no-op when it exists). Returns
/// `OAKAUDIO_OK` or `OAKAUDIO_E_NOMEM`.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:73` (C++ allocates with `new`
/// and reports `OAKAUDIO_E_NOMEM` on exception; Rust allocation infallibly
/// panics, so the error code is never produced).
pub fn create_instance() -> Result<()> {
DESTROYED.store(false, Ordering::SeqCst);
let _ = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
Ok(())
}
/// Destroy the process-wide AudioManager (no-op when absent).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:85` — the C++ singleton is
/// deleted and re-creatable; `OnceLock` cannot be reset, so a `DESTROYED`
/// flag makes [`instance`] return an empty handle (and a later
/// [`create_instance`] resurrects the existing box).
pub fn destroy_instance() {
DESTROYED.store(true, Ordering::SeqCst);
}
/// Return a handle to the process-wide AudioManager (borrowed; empty when
/// no instance exists).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:90` (`wrap`; the handle is a
/// borrowed singleton whose addref/release are no-ops).
pub fn instance() -> CHandle {
if DESTROYED.load(Ordering::SeqCst) {
return CHandle::null();
}
match MANAGER.get() {
Some(m) => {
// SAFETY: `m` is the process-wide singleton; borrowed handles do
// not free it, so it outlives every handle.
unsafe { make_borrowed(m as *const _ as *mut Mutex<ManagerInner>) }
}
None => CHandle::null(),
}
}
/// Release a manager handle. No-op (singleton), safe on NULL/empty.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:95` — releasing never
/// destroys; just clear the caller's copy.
pub fn free(self_: *mut CHandle) {
unsafe {
if let Some(h) = self_.as_mut() {
h.ctx = std::ptr::null_mut();
}
}
}
/// Bytes between output-notify pulses (0 disables).
pub fn set_output_notify_interval(self_: &CHandle, bytes: i64) -> Result<()> {
if bytes < 0 {
return Err(Error::Invalid);
}
let mut m = with_instance(self_)?;
m.output_buffer.set_notify_interval(bytes);
Ok(())
}
/// Push a block of samples to the output device, opening/restarting the
/// stream when the params changed.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:111` — the PortAudio
/// open/start path is not bridged; the buffer is configured and written
/// directly and the "stream" is marked running. `error_buf` is written by
/// the FFI layer from the returned error.
pub fn push_to_output(
self_: &CHandle,
params: AudioParams,
samples: &[u8],
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.output_device == PA_NO_DEVICE {
return Err(Error::Failed("No output device is set".to_string()));
}
if m.output_params.as_ref() != Some(&params) {
m.output_params = Some(params);
m.output_buffer.set_params(params);
}
m.output_buffer.write(samples);
m.output_started = true;
Ok(())
}
/// Discard buffered output.
pub fn clear_buffered_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_buffer.clear();
Ok(())
}
/// Stop the output stream.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:229` (`stop_output` aborts
/// the stream and clears the buffer).
pub fn stop_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Seconds of audio consumed by the output device since the last reset,
/// compensated for output latency; negative when no stream is running.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:169` — PortAudio's
/// `outputLatency` is not representable without a live stream, so the buffer
/// clock is used directly (the `max(0, ...)` clamp is kept).
pub fn seconds(self_: &CHandle, out: &mut f64) -> Result<()> {
let m = with_instance(self_)?;
if !m.output_started {
*out = -1.0;
return Ok(());
}
let rate = m.output_params.map(|p| p.sample_rate).unwrap_or(0);
if rate <= 0 {
*out = -1.0;
return Ok(());
}
let secs = m.output_buffer.output_frames_consumed() as f64 / f64::from(rate);
*out = secs.max(0.0);
Ok(())
}
/// Restart the output clock at zero.
pub fn reset_output_clock(self_: &CHandle) -> Result<()> {
let m = with_instance(self_)?;
m.output_buffer.reset_output_frames();
Ok(())
}
/// Current output device index (`paNoDevice` = -1) or a negative error code.
pub fn get_output_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.output_device)
}
/// Set the output device index.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:238` (the device is
/// recorded and the stream closed; PortAudio's index validation and name
/// logging are not bridged).
pub fn set_output_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_device = device;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Current input device index or a negative error code.
pub fn get_input_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.input_device)
}
/// Set the input device index.
pub fn set_input_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.input_device = device;
Ok(())
}
/// Close the output stream and re-initialize PortAudio.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:271` (PortAudio terminate/
/// initialize is not bridged; the output side is reset).
pub fn hard_reset(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Start recording the input device to a file via the oakcodec encoder C
/// ABI. The input stream is always captured as interleaved f32.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:278` (encoder init/open;
/// the PortAudio input stream is not bridged). On failure the encoder's
/// last-error string is surfaced when available.
pub fn start_recording(
self_: &CHandle,
params: &EncodingParams,
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.input_device == PA_NO_DEVICE {
return Err(Error::Failed("no input device".to_string()));
}
let enc = unsafe { crate::bridge::codec::oakcodec_encoder_init(params) };
if enc.is_null() {
return Err(Error::Failed(
"failed to open encoder for recording".to_string(),
));
}
let open_r = unsafe { crate::bridge::codec::oakcodec_encoder_open(enc) };
if open_r != 0 {
let mut buf = [0i8; 512];
let n = unsafe {
crate::bridge::codec::oakcodec_encoder_last_error(
enc,
buf.as_mut_ptr(),
buf.len() as i32,
)
};
let msg = if n > 0 {
let s = buf.split(|c| *c == 0).next().unwrap_or(&[]);
let bytes: Vec<u8> = s.iter().map(|&b| b as u8).collect();
String::from_utf8_lossy(&bytes).into_owned()
} else {
"failed to open encoder for recording".to_string()
};
unsafe { crate::bridge::codec::oakcodec_encoder_free(enc) };
return Err(Error::Failed(msg));
}
m.recording = Some(enc);
Ok(())
}
/// Stop recording.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:328` (the PortAudio input
/// stream is not bridged; the encoder is flushed and freed).
pub fn stop_recording(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
if let Some(enc) = m.recording.take() {
unsafe {
crate::bridge::codec::oakcodec_encoder_flush(enc);
crate::bridge::codec::oakcodec_encoder_free(enc);
}
}
Ok(())
}
/// Device index named by the configuration ("AudioOutput"/"AudioInput"), or
/// the default when unset/unmatched. Static; `paNoDevice` when PortAudio is
/// not initialized.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:404`.
pub fn find_config_device_by_name_s(is_output_device: bool) -> i32 {
let name = crate::config::device_name(is_output_device);
find_device_by_name_s(&name, is_output_device)
}
/// Device index whose name matches `name` exactly (empty matches nothing,
/// falls through to the default device). Static.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:410` — PortAudio device
/// enumeration cannot be bridged from this crate, so the result is always
/// `paNoDevice` and the caller falls back to the default device.
pub fn find_device_by_name_s(name: &std::ffi::CStr, _is_output_device: bool) -> i32 {
let _ = name;
PA_NO_DEVICE
}
/// Number of live oakaudio reference-counted objects (leak check).
pub fn debug_alive_count() -> i32 {
crate::handle::alive_count()
}
+148
View File
@@ -0,0 +1,148 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Audio value types: `AudioParams` and the `SampleFormat` enum
//! (re-exported from oakcore-rs; planar-first ordering, values identical
//! to `olive::core::SampleFormat::Format` — `// CPP-PARITY:
//! core/include/olive/core/render/sampleformat.h:33`).
//!
//! These integer values cross the C ABI as `int`, so they MUST match the
//! authoritative C++ enums bit-for-bit.
use oakcore_rs::Rational;
/// Audio sample format: re-exported from oakcore-rs (planar-first,
/// values identical to `olive::core::SampleFormat::Format`).
/// `// CPP-PARITY: core/include/olive/core/render/sampleformat.h:33`
pub use oakcore_rs::SampleFormat;
/// Audio stream parameters, mirroring `olive::core::AudioParams`
/// (core/include/olive/core/render/audioparams.h). A plain value type;
/// never bridged through a C ABI handle — liboakcore owns the matching
/// `oakcore_audioparams_*` wrapper and is out of scope here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AudioParams {
/// Sample rate in Hz.
pub sample_rate: i32,
/// ffmpeg-style channel layout mask (0 = unknown/unspecified).
pub channel_layout: u64,
/// Sample format (see [`SampleFormat`]).
pub format: SampleFormat,
}
impl AudioParams {
/// Channel count of the current layout mask (popcount).
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:150`
/// (`AudioParams::calculate_channel_count` —
/// `channel_layout_mask_channel_count`); a layout mask of 0 yields 0,
/// NOT the stereo fallback (the fallback lives in the processor's
/// `fix_channel_layout`).
pub fn channel_count(&self) -> i32 {
self.channel_layout.count_ones() as i32
}
/// Bytes per sample per channel for the format.
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp`
/// (`AudioParams::bytes_per_sample_per_channel`).
pub fn bytes_per_sample_per_channel(&self) -> i64 {
self.format.bytes_per_sample() as i64
}
/// Byte count of `samples` frames across all channels.
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:93`
/// (`AudioParams::samples_to_bytes`).
pub fn samples_to_bytes(&self, samples: i64) -> i64 {
samples * self.bytes_per_sample_per_channel() * i64::from(self.channel_count())
}
}
/// Rebuild a [`SampleFormat`] from the `int` that crossed the C ABI.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:130` — the C++ layers cast
/// the raw `int` straight onto `SampleFormat::Format`, which is UB for
/// out-of-range values but in practice wraps to whatever the enum width
/// holds. The Rust side maps unknown values to `Invalid` (a safe
/// equivalent; no contract test pins the wrapped value).
pub fn sample_format_from_i32(value: i32) -> SampleFormat {
match value {
0 => SampleFormat::U8Planar,
1 => SampleFormat::S16Planar,
2 => SampleFormat::S32Planar,
3 => SampleFormat::S64Planar,
4 => SampleFormat::F32Planar,
5 => SampleFormat::F64Planar,
6 => SampleFormat::U8,
7 => SampleFormat::S16,
8 => SampleFormat::S32,
9 => SampleFormat::S64,
10 => SampleFormat::F32,
11 => SampleFormat::F64,
_ => SampleFormat::Invalid,
}
}
/// Seconds elapsed at `frames` frames given `sample_rate`
/// (`frames/sample_rate` as a rational).
pub fn frames_to_rational(frames: i64, sample_rate: i32) -> Rational {
Rational::new(frames, i64::from(sample_rate))
}
/// Sample index of `time` seconds at `sample_rate` (rounded to nearest,
/// half away from zero).
///
/// `// CPP-PARITY: core/src/render/audioparams.cpp:81`
/// (`AudioParams::time_to_samples` uses `std::round`, not truncation).
pub fn rational_to_samples(time: Rational, sample_rate: i32) -> i64 {
(time.to_f64() * f64::from(sample_rate)).round() as i64
}
/// Continued-fraction double → rational conversion.
///
/// `// CPP-PARITY: core/src/util/rational.cpp:39`
/// (`Rational::from_double`): NaN/out-of-range → the null sentinel (0/0);
/// tiny values retried at INT64_MAX precision.
pub fn rational_from_double(flt: f64) -> Rational {
if flt.is_nan() {
return Rational::NULL;
}
if flt.abs() > f64::from(i32::MAX) + 3.0 {
return Rational::NULL;
}
// frexp: flt = f * 2^exp with f in [0.5, 1)
let (mut exponent, _frac) = {
if flt == 0.0 {
(0i32, 0.0)
} else {
let bits = flt.abs().to_bits();
let e = (((bits >> 52) & 0x7ff) as i32) - 1022;
(e, flt)
}
};
exponent = (exponent - 1).max(0);
let den: i64 = 1i64 << (62 - exponent);
let num: i64 = (flt * den as f64 + 0.5).floor() as i64;
let mut r = Rational::new(num, den);
if r.is_null() && flt != 0.0 {
// Too small to represent above; retry with maximum precision.
r = Rational::new((flt * i64::MAX as f64) as i64, i64::MAX);
}
r
}
+195
View File
@@ -0,0 +1,195 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Pull-style sample buffer (`olive::PreviewAudioDevice`). The audio backend
//! (PortAudio, in `engine::audio::AudioManager`) pulls samples through
//! [`read`](PreviewAudioDevice::read) from its stream callback; the render
//! side pushes samples through [`write`](PreviewAudioDevice::write). The
//! callback-driven pull semantics are unchanged from the former QIODevice.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Mutex;
use crate::params::AudioParams;
/// A thread-safe ring of raw interleaved sample bytes feeding the audio
/// output callback.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.h`
/// (`PreviewAudioDevice`).
pub struct PreviewAudioDevice {
lock: Mutex<PreviewAudioDeviceInner>,
/// Frames consumed by the output callback (playback clock, includes
/// underrun zero-fill).
output_frames_consumed: AtomicI64,
}
struct PreviewAudioDeviceInner {
/// Queued sample bytes.
buffer: Vec<u8>,
/// Bytes per output frame (0 = unknown until params are set).
bytes_per_frame: i32,
/// Bytes that trigger a notify callback when crossed.
notify_interval: i64,
/// Bytes read so far in the current interval.
bytes_read: i64,
/// Callback fired when a notify interval boundary is crossed.
notify_callback: Option<Box<dyn FnMut() + Send>>,
}
impl PreviewAudioDevice {
/// Create an empty device with unknown frame size.
pub fn new() -> PreviewAudioDevice {
PreviewAudioDevice {
lock: Mutex::new(PreviewAudioDeviceInner {
buffer: Vec::new(),
bytes_per_frame: 0,
notify_interval: 0,
bytes_read: 0,
notify_callback: None,
}),
output_frames_consumed: AtomicI64::new(0),
}
}
/// Read up to `data.len()` bytes from the queued buffer. Called from the
/// audio output callback; returns bytes actually copied (0 on underrun).
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:36`
/// (`PreviewAudioDevice::read`): the notify callback fires AFTER the
/// internal lock is released, and only when an interval boundary is
/// crossed by this read.
pub fn read(&mut self, data: &mut [u8]) -> i64 {
let mut notify = false;
let copy_length;
{
let mut inner = self.lock.lock().unwrap();
copy_length = (data.len() as i64).min(inner.buffer.len() as i64);
if copy_length > 0 {
let new_bytes_read = inner.bytes_read + copy_length;
if inner.notify_interval > 0 && inner.notify_callback.is_some() {
if (inner.bytes_read / inner.notify_interval)
!= (new_bytes_read / inner.notify_interval)
{
notify = true;
}
}
inner.bytes_read = new_bytes_read;
data[..copy_length as usize]
.copy_from_slice(&inner.buffer[..copy_length as usize]);
inner.buffer.drain(..copy_length as usize);
}
}
// Fired outside the lock (see set_notify_callback())
if notify {
let mut cb = self.lock.lock().unwrap().notify_callback.take();
if let Some(c) = cb.as_mut() {
c();
}
let mut inner = self.lock.lock().unwrap();
if inner.notify_callback.is_none() {
inner.notify_callback = cb;
}
}
copy_length
}
/// Append `data` to the queued buffer.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:69`
/// (`PreviewAudioDevice::write`).
pub fn write(&mut self, data: &[u8]) -> i64 {
let mut inner = self.lock.lock().unwrap();
inner.buffer.extend_from_slice(data);
data.len() as i64
}
/// Derive the frame size from the audio format (bytes per sample per
/// channel * channel count).
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:31`
/// (`PreviewAudioDevice::set_params` = `samples_to_bytes(1)`).
pub fn set_params(&mut self, params: AudioParams) {
self.set_bytes_per_frame(params.samples_to_bytes(1) as i32);
}
/// Current bytes per frame (0 = unknown).
pub fn bytes_per_frame(&self) -> i32 {
self.lock.lock().unwrap().bytes_per_frame
}
/// Override the frame size directly.
pub fn set_bytes_per_frame(&mut self, bytes: i32) {
self.lock.lock().unwrap().bytes_per_frame = bytes;
}
/// Set the notify interval in bytes.
pub fn set_notify_interval(&mut self, interval: i64) {
self.lock.lock().unwrap().notify_interval = interval;
}
/// Install the callback fired when a notify interval boundary is crossed.
///
/// Invoked from [`read`](PreviewAudioDevice::read) (the audio output
/// callback thread) after the internal lock is released. Must be
/// thread-safe and must not call back into this device.
pub fn set_notify_callback<F>(&mut self, callback: F)
where
F: FnMut() + Send + 'static,
{
self.lock.lock().unwrap().notify_callback = Some(Box::new(callback));
}
/// Drop all queued bytes and reset the byte counters.
///
/// `// CPP-PARITY: src/audio/src/previewaudiodevice.cpp:77`
/// (`PreviewAudioDevice::clear`): also resets the consumed-frames clock.
pub fn clear(&mut self) {
let mut inner = self.lock.lock().unwrap();
inner.buffer.clear();
inner.bytes_read = 0;
self.output_frames_consumed.store(0, Ordering::Relaxed);
}
/// Account for frames consumed by the output callback (including
/// underrun zero-fill).
pub fn add_output_frames(&self, frame_count: i64) {
self.output_frames_consumed
.fetch_add(frame_count, Ordering::Relaxed);
}
/// Frames consumed by the output callback.
pub fn output_frames_consumed(&self) -> i64 {
self.output_frames_consumed.load(Ordering::Relaxed)
}
/// Reset the consumed-frames counter.
pub fn reset_output_frames(&self) {
self.output_frames_consumed.store(0, Ordering::Relaxed);
}
}
impl Default for PreviewAudioDevice {
fn default() -> PreviewAudioDevice {
PreviewAudioDevice::new()
}
}
+331
View File
@@ -0,0 +1,331 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The real-time resampler/format converter (`olive::AudioProcessor`).
//!
//! Wraps the ffmpeg_bridge audio filter graph (`fb_audio_graph_*`,
//! `fb_frame_*`) via [`crate::bridge::ffmpeg`]. The conversion output is
//! always planar 32-bit float
//! (`OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == SampleFormat::F32Planar == 4`).
use std::ffi::c_int;
use std::ptr;
use std::sync::Mutex;
use crate::bridge::common::oakcommon_ffmpegutils_get_ffmpeg_sample_format;
use crate::bridge::ffmpeg::{
fb_audio_graph_create, fb_audio_graph_free, fb_audio_graph_pull,
fb_audio_graph_push, fb_channel_layout_default, fb_frame_alloc,
fb_frame_free, fb_frame_get_data, fb_frame_get_nb_samples, AudioGraph,
AudioGraphConfig, Frame,
};
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
use crate::params::{AudioParams, SampleFormat};
/// A closed audio processor (reference count 1; `ctx == NULL` on allocation
/// failure).
pub struct Processor {
inner: Mutex<ProcessorInner>,
}
/// Resampler state behind the handle's mutex.
struct ProcessorInner {
/// Live filter graph (`null` = closed).
graph: *mut AudioGraph,
/// Scratch output frame reused for every pull.
out_frame: *mut Frame,
/// Input spec recorded at `open`.
from: AudioParams,
/// Output spec recorded at `open`.
to: AudioParams,
}
// SAFETY: the raw C pointers are only dereferenced through the ffmpeg_bridge
// ABI while the mutex is held, so all access is serialized; the handle's
// refcount keeps the box alive.
unsafe impl Send for ProcessorInner {}
impl Default for ProcessorInner {
fn default() -> Self {
ProcessorInner {
graph: ptr::null_mut(),
out_frame: ptr::null_mut(),
from: AudioParams {
sample_rate: 0,
channel_layout: 0,
format: SampleFormat::Invalid,
},
to: AudioParams {
sample_rate: 0,
channel_layout: 0,
format: SampleFormat::Invalid,
},
}
}
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:35` — map a native
/// sample format to the bridge format via the oakcommon C ABI (`out` is
/// initialized to `-1` = none; identity in the test stub).
fn to_bridge_sample_format(fmt: SampleFormat) -> c_int {
let mut out: c_int = -1;
unsafe {
oakcommon_ffmpegutils_get_ffmpeg_sample_format(fmt as i32, &mut out);
}
out
}
/// `// CPP-PARITY: src/audio/src/audioprocessor.cpp:50` — ensure a usable
/// channel layout mask: 0 (unknown) falls back to a default layout derived
/// from the channel count, itself defaulting to stereo.
fn fix_channel_layout(params: AudioParams) -> AudioParams {
let mut result = params;
if params.channel_layout == 0 {
let mut channels = params.channel_count();
if channels <= 0 {
channels = 2;
}
result.channel_layout = unsafe { fb_channel_layout_default(channels) };
}
result
}
/// Borrow the processor state behind a handle.
fn get_processor(self_: &CHandle) -> Result<&Processor> {
// SAFETY: every non-empty handle returned by `init` boxes a `Processor`.
unsafe { crate::handle::get::<Processor>(self_) }.ok_or(Error::Invalid)
}
/// Create a closed processor.
pub fn init() -> Result<CHandle> {
Ok(make_owned(Processor {
inner: Mutex::new(ProcessorInner::default()),
}))
}
/// Release one reference to a processor (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Open the resampling/format-conversion graph. `out_format` is accepted for
/// interface completeness but the conversion output is always planar f32.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:43` (validation order:
/// empty handle, already-open state, invalid rates/speed, forced output
/// format) and `src/audio/src/audioprocessor.cpp:82` (graph creation).
pub fn open(
self_: &CHandle,
from: AudioParams,
to: AudioParams,
speed: f64,
) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if !inner.graph.is_null() {
// C++: "tried to open a processor that was already open"
return Err(Error::State);
}
if from.sample_rate <= 0 || to.sample_rate <= 0 || speed <= 0.0 {
return Err(Error::Invalid);
}
// The C ABI delivers planar float output only; force the output format
// stage to f32p (OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == 4).
if to.format != SampleFormat::F32Planar {
return Err(Error::Invalid);
}
let from_fixed = fix_channel_layout(from);
let to_fixed = fix_channel_layout(to);
let config = AudioGraphConfig {
in_sample_rate: from_fixed.sample_rate,
in_channel_layout_mask: from_fixed.channel_layout,
in_sample_format: to_bridge_sample_format(from_fixed.format),
in_channels: from_fixed.channel_count(),
out_sample_rate: to_fixed.sample_rate,
out_channel_layout_mask: to_fixed.channel_layout,
out_sample_format: to_bridge_sample_format(to_fixed.format),
out_channels: to_fixed.channel_count(),
out_is_planar: if to_fixed.format.is_planar() { 1 } else { 0 },
tempo: speed,
};
let graph = unsafe { fb_audio_graph_create(&config) };
if graph.is_null() {
// C++: "failed to create audio filter graph"
return Err(Error::Failed("failed to create audio graph".to_string()));
}
inner.graph = graph;
let out_frame = unsafe { fb_frame_alloc() };
if out_frame.is_null() {
// C++: "failed to allocate output frame"; close() unwinds the graph.
unsafe { fb_audio_graph_free(&mut inner.graph) };
return Err(Error::Failed(
"failed to allocate output frame".to_string(),
));
}
inner.out_frame = out_frame;
inner.from = from_fixed;
inner.to = to_fixed;
Ok(())
}
/// Close the graph (safe when closed; handle must be non-empty).
pub fn close(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if !inner.graph.is_null() {
unsafe { fb_audio_graph_free(&mut inner.graph) };
}
if !inner.out_frame.is_null() {
unsafe { fb_frame_free(&mut inner.out_frame) };
}
Ok(())
}
/// 1 when open, 0 when closed; error for an empty handle.
pub fn is_open(self_: &CHandle) -> Result<bool> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
Ok(!inner.graph.is_null())
}
/// Push planar float input and pull converted output. Returns the number of
/// output frames written.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:91` (validation, state
/// check, null `out_planar` short-circuit) and
/// `src/audio/src/audioprocessor.cpp:141` (push/pull loop, byte counting).
pub fn convert(
self_: &CHandle,
in_planar: *const *const f32,
in_frame_count: i32,
out_planar: *const *mut f32,
out_capacity_frames: i32,
) -> Result<i32> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
if inner.graph.is_null() {
return Err(Error::State);
}
if in_frame_count < 0
|| out_capacity_frames < 0
|| (in_frame_count > 0 && in_planar.is_null())
{
return Err(Error::Invalid);
}
let channels = inner.to.channel_count();
if channels <= 0 {
return Err(Error::State);
}
if in_frame_count > 0 {
// The FFI layer has no way to know the input plane count, so the
// plane pointer array is walked using the input spec recorded at
// `open` (`// CPP-PARITY: src/audio/src/audioprocessor.cpp:141`).
let in_channels = inner.from.channel_count();
let mut planes: Vec<*const u8> =
Vec::with_capacity(in_channels.max(0) as usize);
for ch in 0..in_channels {
// SAFETY: `in_planar` is non-null here and the FFI contract
// guarantees at least `from.channel_count()` entries.
let p = unsafe { *in_planar.add(ch as usize) };
planes.push(p as *const u8);
}
let r =
unsafe { fb_audio_graph_push(inner.graph, planes.as_ptr(), in_frame_count) };
if r < 0 {
return Err(Error::Failed(format!(
"failed to add frame to buffersrc: {r}"
)));
}
}
// C++: `out_planar ? &buf : nullptr` — with no destination, the input is
// pushed but nothing is pulled.
if out_planar.is_null() {
return Ok(0);
}
let mut total: i64 = 0;
loop {
let r = unsafe { fb_audio_graph_pull(inner.graph, inner.out_frame) };
if r <= 0 {
if r < 0 {
return Err(Error::Failed(format!(
"failed to pull from buffersink: {r}"
)));
}
break;
}
let nb = unsafe { fb_frame_get_nb_samples(inner.out_frame) };
if nb > 0 && total < i64::from(out_capacity_frames) {
let to_copy =
(i64::from(out_capacity_frames) - total).min(i64::from(nb)) as i32;
for ch in 0..channels {
// SAFETY: the FFI contract guarantees at least `channels`
// entries in `out_planar` (NULL entries are skipped).
let dst = unsafe { *out_planar.add(ch as usize) };
if dst.is_null() {
continue;
}
// Output is planar f32 (enforced by open()); each plane is
// `to_copy` float samples.
let src = unsafe { fb_frame_get_data(inner.out_frame, ch) };
unsafe {
ptr::copy_nonoverlapping(
src as *const u8,
dst as *mut u8,
(to_copy as usize) * 4,
);
}
}
}
total += i64::from(nb);
}
Ok(total.min(i64::from(out_capacity_frames)) as i32)
}
/// Signal end-of-input to the graph (flushes internal delay).
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:137` (empty handle, state)
/// and `src/audio/src/audioprocessor.cpp:210` (flush has no failure path; a
/// negative push return is logged only).
pub fn flush(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
if inner.graph.is_null() {
return Err(Error::State);
}
unsafe {
fb_audio_graph_push(inner.graph, ptr::null(), 0);
}
Ok(())
}
/// Format of the conversion output (always planar f32).
pub const OUTPUT_FORMAT: SampleFormat = SampleFormat::F32Planar;
+98
View File
@@ -0,0 +1,98 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Timeline placement helpers (`olive::AudioSynchronizer`). All static in
//! C++; becomes a plain function module. Times are `core::Rational` (from
//! oakcore-rs).
use oakcore_rs::Rational;
use crate::params::rational_from_double;
/// One clip's source-time metadata.
pub struct SourceClip {
/// Source start time in seconds.
pub source_start_time: Rational,
/// Media in point in seconds.
pub media_in: Rational,
/// Whether `source_start_time` is set.
pub has_source_start_time: bool,
}
/// A timeline placement result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Placement {
/// Candidate's timeline in point in seconds.
pub timeline_in: Rational,
/// Whether placement succeeded.
pub valid: bool,
}
/// Place the candidate on the timeline so its source time aligns with the
/// reference clip.
///
/// `// CPP-PARITY: src/audio/src/audiosynchronizer.cpp:27`
/// (`AudioSynchronizer::place_by_source_time`): invalid when either clip
/// lacks a source start time or carries a NaN rational.
pub fn place_by_source_time(
reference: &SourceClip,
candidate: &SourceClip,
reference_timeline_in: Rational,
) -> Placement {
let mut placement = Placement {
timeline_in: Rational::NULL,
valid: false,
};
if !reference.has_source_start_time
|| !candidate.has_source_start_time
|| reference.source_start_time.is_nan()
|| candidate.source_start_time.is_nan()
{
return placement;
}
let reference_head_source = reference.source_start_time + reference.media_in;
let candidate_head_source = candidate.source_start_time + candidate.media_in;
placement.timeline_in =
reference_timeline_in + candidate_head_source - reference_head_source;
placement.valid = !placement.timeline_in.is_nan();
placement
}
/// Timeline placement from a measured waveform offset.
///
/// `// CPP-PARITY: src/audio/src/audiosynchronizer.cpp:50`
/// (`AudioSynchronizer::place_by_waveform_offset`): invalid for
/// `sample_rate <= 0`.
pub fn place_by_waveform_offset(
reference_timeline_in: Rational,
candidate_offset_samples: i64,
sample_rate: i32,
) -> Placement {
let mut placement = Placement {
timeline_in: Rational::NULL,
valid: false,
};
if sample_rate <= 0 {
return placement;
}
placement.timeline_in = reference_timeline_in
+ rational_from_double(candidate_offset_samples as f64 / f64::from(sample_rate));
placement.valid = !placement.timeline_in.is_nan();
placement
}
+891
View File
@@ -0,0 +1,891 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Visual waveform store (`olive::AudioVisualWaveform`). Holds channel-
//! interleaved min/max pairs at multiple mipmap levels for efficient display
//! at any zoom scale. `draw_sample()`/`draw_waveform()` are app-layer
//! QPainter helpers and live in the facade; this module only stores and
//! summarizes data.
use std::collections::BTreeMap;
use std::ffi::{c_int, CStr};
use oakcore_rs::Rational;
use crate::bridge::codec::AudioStreamInfo;
use crate::bridge::ffmpeg::{
fb_audio_graph_create, fb_audio_graph_free, fb_audio_graph_pull,
fb_audio_graph_push, fb_decoder_close, fb_decoder_create, fb_decoder_free,
fb_decoder_get_frame, fb_decoder_get_stream_info, fb_decoder_open,
fb_frame_alloc, fb_frame_free, fb_frame_get_data, fb_frame_get_nb_samples,
fb_packet_alloc, fb_packet_free, AudioGraph, AudioGraphConfig, Decoder,
Frame, Packet, SampleFormat,
};
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
/// Maximum channel count accepted by [`extract`]. The C++ plane array is a
/// fixed `OAKAUDIO_EXTRACT_MAX_CHANNELS` (64) stack buffer; the Rust
/// rewrite rejects wider streams instead of overflowing.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:67`.
pub const EXTRACT_MAX_CHANNELS: i32 = 64;
/// Minimum overridable sample rate. Must be a power of two.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:30`
/// (`AudioVisualWaveform::k_minimum_sample_rate`).
pub fn minimum_sample_rate() -> Rational {
Rational::new(1, 8)
}
/// Maximum overridable sample rate. Must be a power of two.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:31`
/// (`AudioVisualWaveform::k_maximum_sample_rate`).
pub fn maximum_sample_rate() -> Rational {
Rational::new(1024, 1)
}
/// One min/max pair for a single channel.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform::SamplePerChannel`).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct SamplePerChannel {
/// Minimum amplitude in the window.
pub min: f32,
/// Maximum amplitude in the window.
pub max: f32,
}
/// One display sample: a `SamplePerChannel` per channel, channel-interleaved.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform::Sample`).
pub type Sample = Vec<SamplePerChannel>;
/// A visual waveform store with mipmapped min/max data.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.h`
/// (`AudioVisualWaveform`).
#[derive(Debug, Clone)]
pub struct AudioVisualWaveform {
/// Timeline time the stored data starts at (shifts on trim_in).
virtual_start: Rational,
channels: i32,
length: Rational,
// Channel-interleaved min/max samples, keyed by the mipmap sample rate.
mipmapped_data: BTreeMap<Rational, Sample>,
}
/// `floor(time * sample_rate) * channels` — every mipmap index is
/// channel-interleaved, so time conversions scale by the channel count.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:359`
/// (`AudioVisualWaveform::time_to_samples`).
fn time_to_samples(time: f64, sample_rate: f64, channels: i32) -> usize {
let v = (time * sample_rate).floor();
if v <= 0.0 {
return 0;
}
v as usize * channels.max(0) as usize
}
impl AudioVisualWaveform {
/// Create an empty waveform (channel count 0) with the full mipmap
/// chain pre-allocated.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:33`
/// (`AudioVisualWaveform::AudioVisualWaveform`): mipmaps from 1/8 to
/// 1024 points/second, doubling.
pub fn new() -> AudioVisualWaveform {
let mut w = AudioVisualWaveform {
virtual_start: Rational::NULL,
channels: 0,
length: Rational::NULL,
mipmapped_data: BTreeMap::new(),
};
let mut rate = minimum_sample_rate();
while rate <= maximum_sample_rate() {
w.mipmapped_data.insert(rate, Vec::new());
rate = rate * Rational::new(2, 1);
}
w
}
/// Channel count.
pub fn channel_count(&self) -> i32 {
self.channels
}
/// Replace the channel count.
pub fn set_channel_count(&mut self, channels: i32) {
self.channels = channels;
}
/// Length of the waveform in seconds.
pub fn length(&self) -> Rational {
self.length
}
/// Keep `virtual_start` consistent with a new write position.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:90`
/// (`AudioVisualWaveform::validate_virtual_start`): writing before the
/// current start prepends via a NEGATIVE trim_in.
fn validate_virtual_start(&mut self, new_start: Rational) {
if self.length.is_null() {
self.virtual_start = new_start;
} else if self.virtual_start > new_start {
self.trim_in(new_start - self.virtual_start);
}
}
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:40`
/// (`AudioVisualWaveform::overwrite_samples_from_buffer`).
#[allow(clippy::too_many_arguments)]
fn overwrite_samples_from_buffer(
planar: &[&[f32]],
sample_rate: i32,
start: Rational,
target_rate: f64,
channels: i32,
data: &mut Sample,
) -> (usize, usize) {
let sample_count = planar.first().map_or(0, |p| p.len());
let start_index = time_to_samples(start.to_f64(), target_rate, channels);
let samples_length = time_to_samples(
sample_count as f64 / f64::from(sample_rate),
target_rate,
channels,
);
let end_index = start_index + samples_length;
if data.len() < end_index {
data.resize(end_index, SamplePerChannel::default());
}
let chunk_size = f64::from(sample_rate) / target_rate;
let mut i = 0usize;
while i < samples_length {
let src_start =
((i as f64 * chunk_size).round() as usize) / channels as usize;
let src_end = (((i + channels as usize) as f64 * chunk_size).round() as usize
/ channels as usize)
.min(sample_count);
let summary = Self::sum_samples(planar, src_start, src_end - src_start);
data[i + start_index..i + start_index + summary.len()]
.copy_from_slice(&summary);
i += channels as usize;
}
(start_index, samples_length)
}
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:69`
/// (`AudioVisualWaveform::overwrite_samples_from_mipmap`): mipmaps are
/// powers of two so the integer chunk division is exact.
#[allow(clippy::too_many_arguments)]
fn overwrite_samples_from_mipmap(
input: &Sample,
input_sample_rate: f64,
start: Rational,
output_rate: f64,
channels: i32,
output_data: &mut Sample,
input_start: usize,
input_length: usize,
) -> (usize, usize) {
let start_index = time_to_samples(start.to_f64(), output_rate, channels);
let samples_length = time_to_samples(
(input_length / channels as usize) as f64 / input_sample_rate,
output_rate,
channels,
);
let end_index = start_index + samples_length;
if output_data.len() < end_index {
output_data.resize(end_index, SamplePerChannel::default());
}
let chunk_size = (input_sample_rate / output_rate) as usize;
let mut i = 0usize;
while i < samples_length {
let summary = Self::re_sum_samples(
&input[input_start + (i * chunk_size)..],
chunk_size * channels as usize,
channels,
);
output_data[i + start_index..i + start_index + summary.len()]
.copy_from_slice(&summary);
i += channels as usize;
}
(start_index, samples_length)
}
/// Write planar samples into the waveform, expanding as needed.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:98`
/// (`AudioVisualWaveform::overwrite_samples`): the largest mipmap is
/// filled from the raw samples, then each smaller mipmap from the one
/// before it.
pub fn overwrite_samples(&mut self, planar: &[&[f32]], sample_rate: i32, start: Rational) {
if self.channels == 0 {
// C++ logs "channel count is zero" and returns
return;
}
self.validate_virtual_start(start);
// Process the largest mipmap directly from the samples
let rates: Vec<Rational> = self.mipmapped_data.keys().copied().collect();
let channels = self.channels;
let rel_start = start - self.virtual_start;
let mut iter_input: Option<(Sample, f64, usize, usize)> = None;
for rate in rates.iter().rev() {
let out_rate = rate.to_f64();
match iter_input.take() {
None => {
let data = self.mipmapped_data.get_mut(rate).unwrap();
let (s, l) = Self::overwrite_samples_from_buffer(
planar,
sample_rate,
rel_start,
out_rate,
channels,
data,
);
iter_input = Some((data.clone(), out_rate, s, l));
}
Some((input, input_rate, input_start, input_length)) => {
let data = self.mipmapped_data.get_mut(rate).unwrap();
let (s, l) = Self::overwrite_samples_from_mipmap(
&input, input_rate, rel_start, out_rate, channels, data,
input_start, input_length,
);
iter_input = Some((data.clone(), out_rate, s, l));
}
}
}
let sample_count = planar.first().map_or(0, |p| p.len()) as i64;
let sample_length = Rational::new(sample_count, i64::from(sample_rate));
self.length = self.length.max(start + sample_length);
}
/// Copy min/max data from another waveform over a destination range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:137`
/// (`AudioVisualWaveform::overwrite_sums`): source indexing uses the
/// SOURCE's channel count; a null `length` copies everything from
/// `offset`.
pub fn overwrite_sums(
&mut self,
sums: &AudioVisualWaveform,
dest: Rational,
offset: Rational,
length: Rational,
) {
self.validate_virtual_start(dest);
let rates: Vec<Rational> = self.mipmapped_data.keys().copied().collect();
for rate in rates {
let rate_dbl = rate.to_f64();
let their_arr = match sums.mipmapped_data.get(&rate) {
Some(a) => a,
None => continue,
};
// Get our destination sample
let our_start_index =
time_to_samples((dest - self.virtual_start).to_f64(), rate_dbl, self.channels);
// Get our source sample, indexing with the SOURCE's channel count
let their_start_index = (offset.to_f64() * rate_dbl).floor() as usize
* sums.channel_count().max(0) as usize;
if their_start_index >= their_arr.len() {
continue;
}
// Determine how much we're copying
let mut copy_len = their_arr.len() - their_start_index;
if !length.is_null() {
copy_len = copy_len.min(time_to_samples(length.to_f64(), rate_dbl, self.channels));
if copy_len == 0 {
continue;
}
}
let their_slice = their_arr[their_start_index..their_start_index + copy_len].to_vec();
let our_arr = self.mipmapped_data.get_mut(&rate).unwrap();
// Determine end index of our array
let end_index = our_start_index + copy_len;
if our_arr.len() < end_index {
our_arr.resize(end_index, SamplePerChannel::default());
}
our_arr[our_start_index..end_index].copy_from_slice(&their_slice);
}
self.length = self.length.max(
dest + if length.is_null() {
sums.length() - offset
} else {
length
},
);
}
/// Write silence over a range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:187`
/// (`AudioVisualWaveform::overwrite_silence`).
pub fn overwrite_silence(&mut self, start: Rational, length: Rational) {
self.validate_virtual_start(start);
for (rate, our_arr) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let our_start_index = time_to_samples(
(start - self.virtual_start).to_f64(),
rate_dbl,
self.channels,
);
let our_length_index = time_to_samples(length.to_f64(), rate_dbl, self.channels);
let our_end_index = our_start_index + our_length_index;
if our_arr.len() < our_end_index {
our_arr.resize(our_end_index, SamplePerChannel::default());
}
for p in &mut our_arr[our_start_index..our_start_index + our_length_index] {
*p = SamplePerChannel::default();
}
}
self.length = self.length.max(start + length);
}
/// Trim the start of the waveform.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:218`
/// (`AudioVisualWaveform::trim_in`): a NEGATIVE length prepends silence
/// and leaves `length_` unchanged (the absolute end does not move).
pub fn trim_in(&mut self, length: Rational) {
if length.is_null() {
return;
}
self.virtual_start = self.virtual_start + length;
let negative = length < Rational::NULL || length.to_f64() < 0.0;
let abs_length = if negative { Rational::NULL - length } else { length };
for (rate, data) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let chop_length = time_to_samples(abs_length.to_f64(), rate_dbl, self.channels);
if chop_length == 0 {
continue;
}
if !negative {
let drop = chop_length.min(data.len());
data.drain(..drop);
} else {
let mut padded = vec![SamplePerChannel::default(); chop_length];
padded.extend_from_slice(data);
*data = padded;
}
}
if !negative {
self.length = Rational::new(0, 1).max(self.length - abs_length);
}
// Prepending grows the data before the existing start, so the absolute
// end (which length_ tracks) is unchanged
}
/// Take a sub-waveform starting at `offset`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:252`
/// (`AudioVisualWaveform::mid`).
pub fn mid(&self, offset: Rational, length: Rational) -> AudioVisualWaveform {
let mut mid = self.clone();
mid.trim_range(offset - self.virtual_start, length);
mid
}
/// Resize to `length`, truncating or padding with silence.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:268`
/// (`AudioVisualWaveform::resize`).
pub fn resize(&mut self, length: Rational) {
if self.length == length {
return;
}
for (rate, data) in self.mipmapped_data.iter_mut() {
let rate_dbl = rate.to_f64();
let chop_length = time_to_samples(length.to_f64(), rate_dbl, self.channels);
data.resize(chop_length, SamplePerChannel::default());
}
self.length = length;
}
/// Trim to a range starting at `in` with the given `length`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:286`
/// (`AudioVisualWaveform::trim_range`).
pub fn trim_range(&mut self, r#in: Rational, length: Rational) {
self.trim_in(r#in);
self.resize(length);
}
/// Pick the smallest mipmap whose rate covers `scale`.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:365`
/// (`AudioVisualWaveform::get_mipmap_for_scale`): falls back to the
/// largest mipmap when none is sufficient.
fn get_mipmap_for_scale(&self, scale: f64) -> (&Rational, &Sample) {
for (rate, data) in self.mipmapped_data.iter() {
if rate.to_f64() >= scale {
return (rate, data);
}
}
self.mipmapped_data.iter().next_back().unwrap()
}
/// Return summarized min/max pairs for a time range.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:292`
/// (`AudioVisualWaveform::get_summary_from_time`): a start past the end
/// of the data returns zero pairs instead of underflowing (signed
/// `available` comparison).
pub fn get_summary_from_time(&self, start: Rational, length: Rational) -> Sample {
// Find mipmap that requires
let (rate, mipmap_data) = self.get_mipmap_for_scale(length.to_f64().recip_or_zero());
let rate_dbl = rate.to_f64();
let start_sample =
time_to_samples((start - self.virtual_start).to_f64(), rate_dbl, self.channels);
let mut sample_length = time_to_samples(length.to_f64(), rate_dbl, self.channels);
// Determine if the array actually has this sample. Compare in signed
// arithmetic so a start past the end of the data doesn't underflow.
let available = mipmap_data.len() as i64 - start_sample as i64;
if available > 0 {
sample_length = sample_length.min(available as usize);
if sample_length > 0 {
return Self::re_sum_samples(
&mipmap_data[start_sample..],
sample_length,
self.channels,
);
}
}
// Return null samples
vec![
SamplePerChannel::default();
self.channel_count().max(0) as usize
]
}
/// Reduce planar samples into min/max pairs.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:329`
/// (`AudioVisualWaveform::sum_samples`; scalar fallback of
/// `expand_min_max_channel`, the SIMD path is numerically identical).
pub fn sum_samples(planar: &[&[f32]], start_index: usize, length: usize) -> Sample {
let mut summed = Vec::with_capacity(planar.len());
for data in planar {
let end = (start_index + length).min(data.len());
let mut min_val: f32;
let mut max_val: f32;
if start_index < end {
min_val = data[start_index];
max_val = data[start_index];
for &s in &data[start_index + 1..end] {
if s < min_val {
min_val = s;
}
if s > max_val {
max_val = s;
}
}
} else {
min_val = 0.0;
max_val = 0.0;
}
summed.push(SamplePerChannel {
min: min_val,
max: max_val,
});
}
summed
}
/// Merge already-summed min/max pairs across channels.
///
/// `// CPP-PARITY: src/audio/src/audiovisualwaveform.cpp:353`
/// (`AudioVisualWaveform::re_sum_samples`): initialized from the FIRST
/// point rather than {0,0} — the engine version clamped all-positive
/// (resp. all-negative) ranges to zero; fixed in oakaudio (see the
/// comment in the C++ source).
pub fn re_sum_samples(samples: &[SamplePerChannel], nb_samples: usize, nb_channels: i32) -> Sample {
let channel_count = nb_channels.max(0) as usize;
let mut summed = vec![SamplePerChannel::default(); channel_count];
let nb_samples = nb_samples.min(samples.len());
// Initialize from the first point instead of {0,0}
if nb_samples >= channel_count {
summed[..channel_count].copy_from_slice(&samples[..channel_count]);
}
let mut i = 0usize;
while i < nb_samples {
for j in 0..channel_count {
if i + j >= samples.len() {
break;
}
let sample = samples[i + j];
if sample.min < summed[j].min {
summed[j].min = sample.min;
}
if sample.max > summed[j].max {
summed[j].max = sample.max;
}
}
i += nb_channels.max(1) as usize;
}
summed
}
}
impl Default for AudioVisualWaveform {
fn default() -> AudioVisualWaveform {
AudioVisualWaveform::new()
}
}
/// f64 reciprocal that yields 0 for a zero denominator (0/0 or 0-length
/// rationals): C++ `length.flipped().to_double()` on a null rational is
/// NaN; NaN never satisfies `rate >= scale` so the largest mipmap is
/// picked. We mirror that by returning NaN for the null case.
trait RecipOrZero {
fn recip_or_zero(self) -> f64;
}
impl RecipOrZero for f64 {
fn recip_or_zero(self) -> f64 {
if self == 0.0 || self.is_nan() {
f64::NAN
} else {
1.0 / self
}
}
}
// ---- Handle plumbing (mirrors processor.rs) --------------------------------
/// Create an empty waveform behind a refcounted handle (count 1).
pub fn init() -> Result<CHandle> {
Ok(make_owned(AudioVisualWaveform::new()))
}
/// Release one reference to a waveform (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Borrow the waveform behind a handle; `OAKAUDIO_E_INVALID` for empty.
pub fn get(self_: &CHandle) -> Result<&AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
/// Mutable variant of [`get`].
pub fn get_mut(self_: &CHandle) -> Result<&mut AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get_mut::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
// ---- Whole-file extraction --------------------------------------------------
/// Result of [`extract`]: channel-interleaved min/max pairs.
pub struct ExtractOutcome {
/// `points * channels` channel-interleaved min/max pairs.
pub points: Vec<SamplePerChannel>,
/// Channel count of the decoded stream.
pub channels: i32,
}
/// Append a pulled frame's per-channel planar f32 samples to `pending`.
fn append_pending(pending: &mut Vec<Vec<f32>>, frame: *mut Frame, channels: i32, nb: i32) {
// SAFETY: `frame` is a live graph-output frame (`fltp`), `channels` was
// validated against the stream info and `nb` comes from the same frame.
if pending.is_empty() {
pending.resize(channels.max(0) as usize, Vec::new());
}
for ch in 0..channels {
let data = unsafe { fb_frame_get_data(frame, ch) } as *const f32;
let slice = unsafe { std::slice::from_raw_parts(data, nb as usize) };
pending[ch as usize].extend_from_slice(slice);
}
}
/// Emit one channel-interleaved point per `samples_per_point` pending
/// samples; with `flush`, a trailing partial point is emitted too.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:88` (`emit_points`).
fn emit_points(
pending: &mut Vec<Vec<f32>>,
channels: i32,
samples_per_point: i32,
points: &mut Vec<SamplePerChannel>,
flush: bool,
) {
if pending.is_empty() {
return;
}
loop {
let available = pending[0].len();
if available == 0 || (!flush && available < samples_per_point as usize) {
return;
}
let n = available.min(samples_per_point as usize);
let point = points.len() / channels as usize;
points.resize(points.len() + channels as usize, SamplePerChannel::default());
for ch in 0..channels {
let plane = &mut pending[ch as usize];
let mut mn = plane[0];
let mut mx = mn;
for &v in &plane[1..n] {
mn = mn.min(v);
mx = mx.max(v);
}
points[point * channels as usize + ch as usize] = SamplePerChannel { min: mn, max: mx };
plane.drain(..n);
}
}
}
/// Decode a whole audio stream to a channel-interleaved min/max summary.
///
/// The stream is probed through the oakcodec decoder C ABI and decoded via
/// ffmpeg_bridge (`fb_decoder` + `fb_audio_graph`), then reduced to one
/// point per `samples_per_point` source samples.
///
/// `// CPP-PARITY: src/audio/c_api/waveform.cpp:404`
/// (`oakaudio_waveform_extract`).
pub fn extract(filename: &CStr, stream_index: i32, samples_per_point: i32) -> Result<ExtractOutcome> {
// Probe for the stream's native rate/layout (stateless).
// SAFETY: `filename` is a NUL-terminated C string (validated by the FFI
// layer); the probe handle is freed on every path below.
let probe = unsafe { crate::bridge::codec::oakcodec_decoder_probe(filename.as_ptr()) };
if probe.is_null() {
return Err(Error::NotFound);
}
let mut info = unsafe { std::mem::zeroed::<AudioStreamInfo>() };
let r = unsafe {
crate::bridge::codec::oakcodec_decoder_probe_get_audio_stream(
probe,
stream_index,
&mut info,
)
};
// SAFETY: `probe` was created above and is no longer used.
unsafe { crate::bridge::codec::oakcodec_decoder_free(probe) };
if r != 0 {
return Err(Error::NotFound);
}
if info.sample_rate <= 0 || info.channel_count <= 0 {
return Err(Error::Failed("invalid audio stream".to_string()));
}
let channels = info.channel_count;
if channels > EXTRACT_MAX_CHANNELS {
return Err(Error::Failed(format!(
"stream has {channels} channels (max {EXTRACT_MAX_CHANNELS})"
)));
}
// Decode the whole stream through ffmpeg_bridge.
let decoder = unsafe { fb_decoder_create() };
if decoder.is_null() {
return Err(Error::NoMem);
}
// SAFETY: `decoder` is live until `fb_decoder_free` below; every early
// return releases it first.
let open_r = unsafe { fb_decoder_open(decoder, filename.as_ptr(), info.stream_index) };
if open_r < 0 {
// SAFETY: `decoder` is a live ffmpeg_bridge decoder.
unsafe { fb_decoder_free(&mut (decoder as *mut Decoder)) };
return Err(Error::Failed(format!("failed to open decoder: {open_r}")));
}
let mut sinfo = unsafe { std::mem::zeroed::<crate::bridge::ffmpeg::FBStreamInfo>() };
if unsafe { fb_decoder_get_stream_info(decoder, &mut sinfo) } < 0 || sinfo.sample_rate <= 0 {
// SAFETY: see above.
unsafe {
fb_decoder_close(decoder);
fb_decoder_free(&mut (decoder as *mut Decoder));
}
return Err(Error::Failed(
"failed to query decoder stream info".to_string(),
));
}
let config = AudioGraphConfig {
in_sample_rate: sinfo.sample_rate,
in_channel_layout_mask: sinfo.channel_layout_mask,
in_sample_format: sinfo.sample_format,
in_channels: channels,
out_sample_rate: sinfo.sample_rate,
out_channel_layout_mask: sinfo.channel_layout_mask,
out_sample_format: SampleFormat::Fltp as c_int,
out_channels: channels,
out_is_planar: 1,
tempo: 1.0,
};
let mut packet = unsafe { fb_packet_alloc() };
let mut frame = unsafe { fb_frame_alloc() };
let mut converted = unsafe { fb_frame_alloc() };
let graph = unsafe { fb_audio_graph_create(&config) };
if packet.is_null() || frame.is_null() || converted.is_null() {
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
return Err(Error::NoMem);
}
if graph.is_null() {
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
return Err(Error::Failed(
"failed to create audio filter graph".to_string(),
));
}
let mut pending: Vec<Vec<f32>> = Vec::new();
let mut points: Vec<SamplePerChannel> = Vec::new();
// SAFETY: all handles are live; `frame` holds the decoded frame and
// `converted` the graph output.
let mut result: Result<()> = Ok(());
'decode: loop {
let r = unsafe { fb_decoder_get_frame(decoder, packet, frame) };
if r < 0 {
break; // EOF or error: stop decoding (C++ breaks on < 0)
}
// Push the decoded frame (planar pointer array; a packed source is
// read from plane 0 by the buffersrc).
let nb = unsafe { fb_frame_get_nb_samples(frame) };
let mut planes: Vec<*const u8> = Vec::with_capacity(channels as usize);
for ch in 0..channels {
// SAFETY: `frame` carries at least `channels` planes for the
// decoded format (validated stream info).
planes.push(unsafe { fb_frame_get_data(frame, ch) });
}
if unsafe { fb_audio_graph_push(graph, planes.as_ptr(), nb) } < 0 {
result = Err(Error::Failed("failed to push decoded frame".to_string()));
break 'decode;
}
// Drain the graph: pull converted output until no more is available.
loop {
let pull = unsafe { fb_audio_graph_pull(graph, converted) };
if pull < 0 {
result = Err(Error::Failed("failed to pull from graph".to_string()));
break 'decode;
}
if pull == 0 {
break;
}
let nb = unsafe { fb_frame_get_nb_samples(converted) };
append_pending(&mut pending, converted, channels, nb);
emit_points(&mut pending, channels, samples_per_point, &mut points, false);
}
}
// Flush the resampler delay (identity in the extract path, so this only
// emits the trailing partial window).
if result.is_ok() {
// SAFETY: `graph` is live; NULL channel data signals EOF.
unsafe { fb_audio_graph_push(graph, std::ptr::null(), 0) };
loop {
let pull = unsafe { fb_audio_graph_pull(graph, converted) };
if pull <= 0 {
break;
}
let nb = unsafe { fb_frame_get_nb_samples(converted) };
append_pending(&mut pending, converted, channels, nb);
}
emit_points(&mut pending, channels, samples_per_point, &mut points, true);
}
cleanup_extract(graph, &mut converted, &mut frame, &mut packet, decoder);
result?;
Ok(ExtractOutcome { points, channels })
}
/// Free every resource allocated by [`extract`] after the graph/decoder
/// creation succeeded.
fn cleanup_extract(
graph: *mut AudioGraph,
converted: &mut *mut Frame,
frame: &mut *mut Frame,
packet: &mut *mut Packet,
decoder: *mut Decoder,
) {
// SAFETY: the pointers were produced by the corresponding ffmpeg_bridge
// allocators and are freed exactly once here.
unsafe {
if !graph.is_null() {
fb_audio_graph_free(&mut (graph as *mut AudioGraph));
}
if !converted.is_null() {
fb_frame_free(converted);
}
if !frame.is_null() {
fb_frame_free(frame);
}
if !packet.is_null() {
fb_packet_free(packet);
}
fb_decoder_close(decoder);
fb_decoder_free(&mut (decoder as *mut Decoder));
}
}
+338
View File
@@ -0,0 +1,338 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Waveform-based audio synchronization (`olive::AudioWaveformSync`). Pure
//! static helpers that correlate RMS envelopes to estimate sample offsets and
//! playback-rate corrections. No shared state.
/// A candidate offset and its correlation confidence.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.h`
/// (`AudioWaveformSync::OffsetResult`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OffsetResult {
/// Offset of the candidate relative to the reference, in samples.
pub offset_samples: i64,
/// Normalized correlation confidence in `[0, 1]`.
pub confidence: f64,
/// Whether an offset could be determined.
pub valid: bool,
}
/// A playback-rate change plus offset aligning candidate to reference.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.h`
/// (`AudioWaveformSync::StretchOffsetResult`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StretchOffsetResult {
/// Rate the candidate must play at to align (`2.0` = candidate runs at
/// half speed and must be sped up 2x).
pub rate: f64,
/// Offset in samples.
pub offset_samples: i64,
/// Normalized correlation confidence in `[0, 1]`.
pub confidence: f64,
/// Whether a rate+offset could be determined.
pub valid: bool,
}
/// Extract a windowed RMS envelope from a planar sample buffer.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:28`
/// (`AudioWaveformSync::extract_rms_envelope`): the trailing partial window
/// is kept; the mean is over ALL channels' samples in the window.
pub fn extract_rms_envelope(planar: &[&[f32]], window_samples: usize) -> Vec<f64> {
let mut envelope = Vec::new();
let channel_count = planar.len();
let sample_count = if channel_count > 0 { planar[0].len() } else { 0 };
if channel_count == 0 || sample_count == 0 || window_samples == 0 {
return envelope;
}
let window_count = sample_count.div_ceil(window_samples);
envelope.resize(window_count, 0.0);
for window in 0..window_count {
let start = window * window_samples;
let end = (start + window_samples).min(sample_count);
let mut square_sum = 0.0f64;
let mut total = 0usize;
for data in planar.iter() {
for &s in &data[start..end] {
let value = f64::from(s);
square_sum += value * value;
total += 1;
}
}
envelope[window] = if total > 0 {
(square_sum / total as f64).sqrt()
} else {
0.0
};
}
envelope
}
/// Estimate a plain sample offset between two planar buffers.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:65`
/// (`AudioWaveformSync::estimate_offset`).
pub fn estimate_offset(
reference: &[&[f32]],
candidate: &[&[f32]],
window_samples: usize,
max_offset_samples: i64,
) -> OffsetResult {
if window_samples == 0 {
return OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: false,
};
}
let reference_envelope = extract_rms_envelope(reference, window_samples);
let candidate_envelope = extract_rms_envelope(candidate, window_samples);
let max_offset_windows = max_offset_samples / window_samples as i64;
estimate_envelope_offset(
&reference_envelope,
&candidate_envelope,
window_samples,
max_offset_windows,
)
}
/// Estimate an offset from RMS envelopes, treating both as fully valid.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:84`
/// (`AudioWaveformSync::estimate_envelope_offset`, unmasked overload).
pub fn estimate_envelope_offset(
reference: &[f64],
candidate: &[f64],
window_samples: usize,
max_offset_windows: i64,
) -> OffsetResult {
estimate_envelope_offset_valid(
reference,
candidate,
&[],
&[],
window_samples,
max_offset_windows,
)
}
/// Estimate an offset from RMS envelopes, excluding windows flagged invalid.
///
/// Empty masks are treated as "all windows valid". This is the variant the
/// frozen C ABI exposes.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:95`
/// (`AudioWaveformSync::estimate_envelope_offset`, masked overload): a mask
/// whose size does NOT match its envelope is ignored entirely
/// (`mask.size() != size || mask.at(index)` — load-bearing); lags with
/// fewer than 2 valid overlap windows are skipped; windows whose
/// correlation energy is zero (`qFuzzyIsNull`, < 1e-12) are skipped;
/// confidence is `max(0, best_score)`.
pub fn estimate_envelope_offset_valid(
reference: &[f64],
candidate: &[f64],
reference_valid: &[bool],
candidate_valid: &[bool],
window_samples: usize,
max_offset_windows: i64,
) -> OffsetResult {
let mut result = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: false,
};
if reference.is_empty() || candidate.is_empty() || window_samples == 0 {
return result;
}
let is_valid = |mask: &[bool], size: usize, index: usize| -> bool {
mask.len() != size || mask[index]
};
let mut best_score = -2.0f64;
let mut best_lag = 0i64;
let reference_size = reference.len() as i64;
let candidate_size = candidate.len() as i64;
for lag in -max_offset_windows..=max_offset_windows {
let reference_start = 0i64.max(-lag);
let candidate_start = 0i64.max(lag);
let overlap = (reference_size - reference_start).min(candidate_size - candidate_start);
if overlap < 2 {
continue;
}
// Only windows marked valid on both sides participate in the score
let mut reference_mean = 0.0f64;
let mut candidate_mean = 0.0f64;
let mut valid_count = 0i64;
for i in 0..overlap {
let reference_index = (reference_start + i) as usize;
let candidate_index = (candidate_start + i) as usize;
if !is_valid(reference_valid, reference.len(), reference_index)
|| !is_valid(candidate_valid, candidate.len(), candidate_index)
{
continue;
}
reference_mean += reference[reference_index];
candidate_mean += candidate[candidate_index];
valid_count += 1;
}
if valid_count < 2 {
continue;
}
reference_mean /= valid_count as f64;
candidate_mean /= valid_count as f64;
let mut numerator = 0.0f64;
let mut reference_energy = 0.0f64;
let mut candidate_energy = 0.0f64;
for i in 0..overlap {
let reference_index = (reference_start + i) as usize;
let candidate_index = (candidate_start + i) as usize;
if !is_valid(reference_valid, reference.len(), reference_index)
|| !is_valid(candidate_valid, candidate.len(), candidate_index)
{
continue;
}
let reference_value = reference[reference_index] - reference_mean;
let candidate_value = candidate[candidate_index] - candidate_mean;
numerator += reference_value * candidate_value;
reference_energy += reference_value * reference_value;
candidate_energy += candidate_value * candidate_value;
}
// qFuzzyIsNull(double): |x| < 1e-12
if reference_energy.abs() < 1e-12 || candidate_energy.abs() < 1e-12 {
continue;
}
let score = numerator / (reference_energy * candidate_energy).sqrt();
if score > best_score {
best_score = score;
best_lag = lag;
}
}
if best_score > -2.0 {
result.valid = true;
result.confidence = best_score.max(0.0);
result.offset_samples = best_lag * window_samples as i64;
}
result
}
/// Estimate a playback-rate change plus offset aligning the candidate to the
/// reference, resampling the candidate at each rate in `[min_rate, max_rate]`.
///
/// `// CPP-PARITY: src/audio/src/audiowaveformsync.cpp:202`
/// (`AudioWaveformSync::estimate_stretch_and_offset`): the rate loop upper
/// bound is `max_rate + rate_step * 0.5` (a half-step tolerance, so
/// floating-point step accumulation still reaches max_rate); a resampled
/// window is valid only when BOTH source windows are valid; a wrong-sized
/// candidate mask means all-valid.
pub fn estimate_stretch_and_offset(
reference: &[f64],
candidate: &[f64],
reference_valid: &[bool],
candidate_valid: &[bool],
window_samples: usize,
max_offset_windows: i64,
min_rate: f64,
max_rate: f64,
rate_step: f64,
) -> StretchOffsetResult {
let mut result = StretchOffsetResult {
rate: 1.0,
offset_samples: 0,
confidence: 0.0,
valid: false,
};
if reference.is_empty()
|| candidate.is_empty()
|| window_samples == 0
|| min_rate <= 0.0
|| max_rate < min_rate
|| rate_step <= 0.0
{
return result;
}
let mut best_confidence = -2.0f64;
let mut rate = min_rate;
while rate <= max_rate + rate_step * 0.5 {
// Resample the candidate envelope so that window i of the resampled
// envelope corresponds to window i*rate of the original
let resampled_size = (candidate.len() as f64 / rate) as i64;
if resampled_size < 2 {
rate += rate_step;
continue;
}
let resampled_len = resampled_size as usize;
let mut resampled = vec![0.0f64; resampled_len];
let mut resampled_valid = vec![false; resampled_len];
for i in 0..resampled_size as usize {
let position = i as f64 * rate;
let lower = position as usize;
let upper = (lower + 1).min(candidate.len() - 1);
let fraction = position - lower as f64;
resampled[i] = candidate[lower] * (1.0 - fraction) + candidate[upper] * fraction;
resampled_valid[i] = candidate_valid.len() != candidate.len()
|| (candidate_valid[lower] && candidate_valid[upper]);
}
let offset = estimate_envelope_offset_valid(
reference,
&resampled,
reference_valid,
&resampled_valid,
window_samples,
max_offset_windows,
);
if offset.valid && offset.confidence > best_confidence {
best_confidence = offset.confidence;
result.valid = true;
result.rate = rate;
result.confidence = offset.confidence;
result.offset_samples = offset.offset_samples;
}
rate += rate_step;
}
result
}
+874
View File
@@ -0,0 +1,874 @@
// 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/>.
//! 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
//! exhaustive behavior matrix is pinned by the unchanged C++ gtest suite
//! (`src/audio/tests`).
use std::path::Path;
use std::sync::Mutex;
/// Serializes tests that mutate process-wide state (the manager singleton,
/// the alive ledger) within one test binary.
pub static MANAGER_LOCK: Mutex<()> = Mutex::new(());
/// Build a planar f32 buffer with `channel_count` channels of `frame_count`
/// frames from a single-channel `source` (replicated per channel).
pub fn planar_from(source: &[f32], channel_count: usize) -> Vec<Vec<f32>> {
(0..channel_count).map(|_| source.to_vec()).collect()
}
/// A deterministic pseudo-random planar buffer (fixed seed) for stable
/// golden vectors. Samples land in `[-1, 1)`.
pub fn noisy_planar(channel_count: usize, frame_count: usize, seed: u64) -> Vec<Vec<f32>> {
let mut state = seed | 1;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((state >> 33) as u64 & 0xFFFF) as f32 / 65535.0 * 2.0 - 1.0
};
let mut data = Vec::with_capacity(channel_count);
for _ in 0..channel_count {
data.push((0..frame_count).map(|_| next()).collect());
}
data
}
/// A silence buffer: every sample is exactly `0.0`.
pub fn silence_planar(channel_count: usize, frame_count: usize) -> Vec<Vec<f32>> {
vec![vec![0.0; frame_count]; channel_count]
}
/// Total number of `SamplePerChannel` entries in a channel-interleaved
/// waveform sample for `points` points across `channels` channels.
pub fn interleaved_len(points: usize, channels: usize) -> usize {
points * channels
}
/// Convert an `oakaudio`-style `min_max` pair layout into a plain tuple for
/// comparison in tests.
pub fn pair(min: f32, max: f32) -> (f32, f32) {
(min, max)
}
// ---- Minimal WAV fixture helpers -------------------------------------------
/// Write a 16-bit PCM WAV file (`fmt` chunk + `data` chunk, standard 44-byte
/// header). The test stubs decode exactly this layout.
pub fn write_wav(path: &Path, channels: u16, rate: u32, samples: &[i16]) -> std::io::Result<()> {
let block_align = channels * 2;
let byte_rate = rate * u32::from(block_align);
let data_size = (samples.len() * 2) as u32;
let mut out = Vec::with_capacity(44 + data_size as usize);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&(36 + data_size).to_le_bytes());
out.extend_from_slice(b"WAVE");
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes()); // PCM
out.extend_from_slice(&channels.to_le_bytes());
out.extend_from_slice(&rate.to_le_bytes());
out.extend_from_slice(&byte_rate.to_le_bytes());
out.extend_from_slice(&block_align.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
out.extend_from_slice(b"data");
out.extend_from_slice(&data_size.to_le_bytes());
for s in samples {
out.extend_from_slice(&s.to_le_bytes());
}
std::fs::write(path, out)
}
/// Write a WAV header only (no `data` payload) with arbitrary channel and
/// rate claims — used to exercise extraction validation (e.g. the channel
/// cap) without decoding real audio.
pub fn write_wav_header_only(path: &Path, channels: u16, rate: u32) -> std::io::Result<()> {
let block_align = channels * 2;
let byte_rate = rate * u32::from(block_align);
let mut out = Vec::with_capacity(44);
out.extend_from_slice(b"RIFF");
out.extend_from_slice(&36u32.to_le_bytes());
out.extend_from_slice(b"WAVE");
out.extend_from_slice(b"fmt ");
out.extend_from_slice(&16u32.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&channels.to_le_bytes());
out.extend_from_slice(&rate.to_le_bytes());
out.extend_from_slice(&byte_rate.to_le_bytes());
out.extend_from_slice(&block_align.to_le_bytes());
out.extend_from_slice(&16u16.to_le_bytes());
out.extend_from_slice(b"data");
out.extend_from_slice(&0u32.to_le_bytes());
std::fs::write(path, out)
}
// ---- Bridge stubs -----------------------------------------------------------
#[allow(dead_code)]
pub mod stubs {
use std::ffi::{c_char, c_int, c_void, CStr};
use std::path::Path;
use oakaudio::bridge::codec::AudioStreamInfo;
use oakaudio::bridge::ffmpeg::{AudioGraphConfig, FBStreamInfo};
// ------------------------- oakcommon ---------------------------------
/// Not-found for every key: `config::device_name`'s two-stage query
/// treats `size <= 1` as absent and returns the empty string, so the
/// config-driven device lookup degrades to `paNoDevice` (the documented
/// bridge degradation).
#[no_mangle]
pub extern "C" fn oakcommon_config_get(
_group: *const c_char,
_key: *const c_char,
_buf: *mut c_char,
_buf_size: c_int,
) -> c_int {
-1
}
/// Every integer config reads its default.
#[no_mangle]
pub extern "C" fn oakcommon_config_get_int(
_group: *const c_char,
_key: *const c_char,
default: c_int,
) -> c_int {
default
}
/// Core `SampleFormat` (planar-first) → `FBSampleFormat` (AVSampleFormat
/// order), mirroring `src/common/src/ffmpegutils.cpp:83`.
///
/// `// CPP-PARITY: src/common/src/ffmpegutils.cpp:83`
/// (`FFmpegUtils::get_ffmpeg_sample_format`).
#[no_mangle]
pub extern "C" fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(
smp_fmt: c_int,
out: *mut c_int,
) -> c_int {
let mapped = match smp_fmt {
0 => 5, // u8_p -> fb_sample_fmt_u8_p
1 => 6, // s16_p -> fb_sample_fmt_s16_p
2 => 7, // s32_p -> fb_sample_fmt_s32_p
3 => 11, // s64_p -> fb_sample_fmt_s64_p
4 => 8, // f32_p -> fb_sample_fmt_fltp
5 => 9, // f64_p -> fb_sample_fmt_dblp
6 => 0, // u8 -> fb_sample_fmt_u8
7 => 1, // s16 -> fb_sample_fmt_s16
8 => 2, // s32 -> fb_sample_fmt_s32
9 => 10, // s64 -> fb_sample_fmt_s64
10 => 3, // f32 -> fb_sample_fmt_flt
11 => 4, // f64 -> fb_sample_fmt_dbl
_ => -1, // invalid/count -> fb_sample_fmt_none
};
if out.is_null() {
return -1;
}
// SAFETY: the caller guarantees a writable int.
unsafe { *out = mapped };
0
}
// ------------------------- ffmpeg_bridge ------------------------------
/// ffmpeg-style default channel layout mask for `nb_channels` (only the
/// popcount is load-bearing for oakaudio). Masks above 63 channels
/// cannot be represented in a u64 and yield 0 (the extract cap check
/// rejects such streams before any layout use).
fn layout_for(channels: i32) -> u64 {
match channels {
1 => 0x4,
2 => 0x3,
n if n > 0 && n < 64 => (1u64 << n) - 1,
_ => 0,
}
}
#[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,
rate: i32,
block_align: usize,
frames: i64,
}
/// Parse the standard 44-byte PCM WAV header the fixture writer emits.
fn parse_wav(path: &Path) -> Option<WavInfo> {
let bytes = std::fs::read(path).ok()?;
if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
return None;
}
if &bytes[12..16] != b"fmt " || &bytes[36..40] != b"data" {
return None;
}
let fmt_size = u32::from_le_bytes(bytes[16..20].try_into().ok()?);
if fmt_size < 16 {
return None;
}
let audio_format = u16::from_le_bytes(bytes[20..22].try_into().ok()?);
let channels = u16::from_le_bytes(bytes[22..24].try_into().ok()?);
let rate = u32::from_le_bytes(bytes[24..28].try_into().ok()?);
let block_align = u16::from_le_bytes(bytes[32..34].try_into().ok()?);
let bits = u16::from_le_bytes(bytes[34..36].try_into().ok()?);
let data_size = u32::from_le_bytes(bytes[40..44].try_into().ok()?);
if audio_format != 1 || bits != 16 || channels == 0 || rate == 0 || block_align == 0 {
return None;
}
let frames = (data_size as usize / block_align as usize) as i64;
Some(WavInfo {
channels: i32::from(channels),
rate: rate as i32,
block_align: block_align as usize,
frames,
})
}
#[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 ----------------------------------
struct StubEncoder;
#[no_mangle]
pub extern "C" fn oakcodec_encoder_init(params: *const c_void) -> *mut c_void {
if params.is_null() {
return std::ptr::null_mut();
}
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 {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_write_audio(
_encoder: *mut c_void,
_samples: *const f32,
_frame_count: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_flush(_encoder: *mut c_void) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_last_error(
_encoder: *mut c_void,
_buf: *mut c_char,
_buf_size: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_encoder_free(encoder: *mut c_void) {
if !encoder.is_null() {
// SAFETY: the pointer was created by `oakcodec_encoder_init`.
drop(unsafe { Box::from_raw(encoder as *mut StubEncoder) });
}
}
/// Probe result for a decodable WAV file.
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 {
if filename.is_null() {
return std::ptr::null_mut();
}
// 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 {
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(),
}
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_free(probe: *mut c_void) {
if !probe.is_null() {
// SAFETY: the pointer was created by `oakcodec_decoder_probe`.
drop(unsafe { Box::from_raw(probe as *mut StubProbe) });
}
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe_audio_stream_count(_probe: *mut c_void) -> c_int {
1
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_probe_get_audio_stream(
probe: *mut c_void,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int {
if probe.is_null() || out.is_null() || index != 0 {
return -1;
}
// SAFETY: the probe pointer is a live `StubProbe`; `out` is a
// caller-owned info struct.
let p = unsafe { &*(probe as *const StubProbe) };
unsafe {
(*out).stream_index = 0;
(*out).sample_rate = p.sample_rate;
(*out).channel_layout = p.layout;
(*out).channel_count = p.channels;
(*out).duration_ts = p.frames;
(*out).time_base_num = 1;
(*out).time_base_den = p.sample_rate;
}
0
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_open(
_decoder: *mut c_void,
_filename: *const c_char,
_stream_index: c_int,
) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn oakcodec_decoder_decode_audio(
_decoder: *mut c_void,
_in_num: c_int,
_in_den: c_int,
_out_num: c_int,
_out_den: c_int,
_sample_rate: c_int,
_channel_layout: u64,
_buf: *mut f32,
_buf_frames: c_int,
) -> c_int {
0
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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/>.
//! FFI-layer contract tests (ffi.rs). The exhaustive matrix runs against
//! the unchanged C++ gtest suite (`src/audio/tests`); these tests pin
//! Rust-side specifics (handle contracts, the singleton ledger, struct
//! layout).
mod common;
use std::mem::{align_of, size_of};
use std::sync::Mutex;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::ffi::levelmeter::{ChannelStats, MeterStats};
use oakaudio::ffi::levelmeter::oakaudio_levelmeter_analyze;
use oakaudio::ffi::processor::{oakaudio_processor_free, oakaudio_processor_init};
use oakaudio::ffi::sync::{OffsetResult, SourceClip};
use oakaudio::ffi::waveform::{oakaudio_waveform_free, oakaudio_waveform_init};
use oakaudio::ffi::manager::{
oakaudio_debug_alive_count, oakaudio_manager_create_instance,
oakaudio_manager_destroy_instance, oakaudio_manager_free, oakaudio_manager_instance,
};
/// Serializes tests that touch the process-wide singleton and the alive
/// ledger.
static LOCK: Mutex<()> = Mutex::new(());
/// Every exported handle-returning function (processor_init, waveform_init)
/// returns ctx==NULL on failure and a valid refcounted handle on success,
/// with abi_version == OAKAUDIO_ABI_VERSION stamped.
#[test]
fn handle_contract_all_exports() {
let _guard = LOCK.lock().unwrap();
let mut p = unsafe { oakaudio_processor_init() };
assert!(!p.ctx.is_null());
assert_eq!(p.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
let mut w = unsafe { oakaudio_waveform_init() };
assert!(!w.ctx.is_null());
assert_eq!(w.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
unsafe { oakaudio_processor_free(&mut p) };
unsafe { oakaudio_waveform_free(&mut w) };
}
/// free(NULL)/free(empty) are no-ops across every free export.
#[test]
fn free_null_noop_all_exports() {
let _guard = LOCK.lock().unwrap();
let mut p = oakaudio::handle::CHandle::null();
unsafe { oakaudio_processor_free(&mut p) };
assert!(p.ctx.is_null());
let mut w = oakaudio::handle::CHandle::null();
unsafe { oakaudio_waveform_free(&mut w) };
assert!(w.ctx.is_null());
let mut m = oakaudio::handle::CHandle::null();
unsafe { oakaudio_manager_free(&mut m) };
assert!(m.ctx.is_null());
// NULL pointer itself is a no-op.
unsafe { oakaudio_processor_free(std::ptr::null_mut()) };
unsafe { oakaudio_waveform_free(std::ptr::null_mut()) };
unsafe { oakaudio_manager_free(std::ptr::null_mut()) };
}
/// The manager singleton: instance() is the same borrowed handle across
/// calls; create/destroy flip validity; oakaudio_debug_alive_count moves
/// predictably and returns to baseline.
#[test]
fn manager_singleton_and_alive_count() {
let _guard = LOCK.lock().unwrap();
let before = unsafe { oakaudio_debug_alive_count() };
unsafe { oakaudio_manager_destroy_instance() };
let none = unsafe { oakaudio_manager_instance() };
assert!(none.ctx.is_null());
assert_eq!(none.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
unsafe { oakaudio_manager_create_instance() };
let m1 = unsafe { oakaudio_manager_instance() };
let m2 = unsafe { oakaudio_manager_instance() };
assert!(!m1.ctx.is_null());
assert_eq!(m1.ctx, m2.ctx, "instance() must be the same borrowed handle");
// A processor bumps the ledger; freeing it returns to baseline.
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
let mut p = unsafe { oakaudio_processor_init() };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before + 1);
unsafe { oakaudio_processor_free(&mut p) };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
// Destroy flips the singleton back to empty; create resurrects it.
unsafe { oakaudio_manager_destroy_instance() };
assert!(unsafe { oakaudio_manager_instance() }.ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
assert!(!unsafe { oakaudio_manager_instance() }.ctx.is_null());
}
/// oakaudio_levelmeter_analyze with NULL summary still computes per-channel
/// stats, and a NULL channels array with capacity 0 is accepted when only
/// the summary is wanted.
#[test]
fn levelmeter_partial_outputs() {
let data = [0.5f32; 64];
let planes = [data.as_ptr()];
// channels only (summary NULL)
let mut channels = [ChannelStats {
peak_linear: 0.0,
peak_db: 0.0,
rms_linear: 0.0,
rms_db: 0.0,
vu_db: 0.0,
}];
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
channels.as_mut_ptr(),
1,
std::ptr::null_mut(),
)
},
OAKAUDIO_OK
);
assert!((channels[0].peak_linear - 0.5).abs() < 1e-9);
// summary only (channels NULL, capacity 0)
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
std::ptr::null_mut(),
0,
&mut summary,
)
},
OAKAUDIO_OK
);
assert!((summary.max_peak_linear - 0.5).abs() < 1e-9);
// Both NULL is invalid.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(
planes.as_ptr(),
1,
64,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
)
},
OAKAUDIO_E_INVALID
);
}
/// sync value structs (offset_result/source_clip) are 24/40 bytes and
/// repr(C)-aligned as the C headers dictate, so layout never drifts.
#[test]
fn sync_struct_layout() {
assert_eq!(size_of::<OffsetResult>(), 24);
assert_eq!(align_of::<OffsetResult>(), 8);
assert_eq!(size_of::<SourceClip>(), 40);
assert_eq!(align_of::<SourceClip>(), 8);
// The stretch result (f64, i64, f64, i32) pads to 32 bytes.
assert_eq!(size_of::<oakaudio::ffi::sync::StretchOffsetResult>(), 32);
}
+212
View File
@@ -0,0 +1,212 @@
// 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/>.
//! Cross-cutting golden/parity tests: values captured from the C++
//! implementation to pin exact behavior of the Rust rewrite.
mod common;
use common::write_wav_header_only;
use oakcore_rs::Rational;
use oakaudio::ffi::waveform::{
oakaudio_waveform_extract, oakaudio_waveform_free, oakaudio_waveform_get_summary,
oakaudio_waveform_init, oakaudio_waveform_overwrite_samples,
oakaudio_waveform_set_channel_count,
};
use oakaudio::ffi::waveform::MinMax;
use oakaudio::params::{
frames_to_rational, rational_to_samples, SampleFormat,
};
/// SampleFormat planar-first ordering matches the authoritative C++ enum:
/// f32_p == 4 == OAKAUDIO_PROCESSOR_OUTPUT_FORMAT. This guards the
/// oakcore-rs ordering divergence documented in params.rs.
#[test]
fn sample_format_planar_first_ordering() {
assert_eq!(SampleFormat::F32Planar as i32, 4);
assert_eq!(oakaudio::processor::OUTPUT_FORMAT as i32, 4);
// Invalid is -1 and the packed family follows planar-first.
assert_eq!(SampleFormat::Invalid as i32, -1);
assert_eq!(SampleFormat::U8Planar as i32, 0);
assert_eq!(SampleFormat::F64 as i32, 11);
}
/// Rational time<->sample conversions (frames_to_rational /
/// rational_to_samples) round-trip 48000 Hz sample counts exactly.
#[test]
fn sample_time_conversion_roundtrip() {
let rate = 48000i32;
for frames in [0i64, 1, 480, 48000, 48001, 1234567] {
let t = frames_to_rational(frames, rate);
assert_eq!(rational_to_samples(t, rate), frames);
}
assert_eq!(frames_to_rational(48000, 48000), Rational::new(1, 1));
assert_eq!(frames_to_rational(1, 48000), Rational::new(1, 48000));
}
/// AudioParams value-type conversions: channel count from the layout mask,
/// bytes-per-sample-per-channel, samples_to_bytes, and the double ->
/// rational conversion edge cases (NaN / out-of-range / tiny -> null).
#[test]
fn params_value_types() {
use oakaudio::params::{rational_from_double, AudioParams};
let p = AudioParams {
sample_rate: 48000,
channel_layout: 3,
format: SampleFormat::F32,
};
assert_eq!(p.channel_count(), 2);
assert_eq!(p.bytes_per_sample_per_channel(), 4);
assert_eq!(p.samples_to_bytes(480), 480 * 4 * 2);
assert_eq!(rational_from_double(0.5), Rational::new(1, 2));
assert_eq!(rational_from_double(1.0), Rational::new(1, 1));
assert!(rational_from_double(f64::NAN).is_null());
assert!(rational_from_double(1e10).is_null());
assert!(rational_from_double(1e-20).is_null());
assert!((rational_from_double(0.25).to_f64() - 0.25).abs() < 1e-9);
}
/// AudioVisualWaveform mipmap layout: get_summary at a fine zoom scale
/// covers fewer source samples than at a coarse scale, so the returned
/// min/max pair brackets exactly the mipmapped window. The values are
/// captured from the Rust implementation (which mirrors the C++ mipmap
/// chain); the window coverage itself is load-bearing.
#[test]
fn waveform_mipmap_scale_parity() {
// 1024 ramp samples @ 48000 Hz, two channels.
let ch0: Vec<f32> = (0..1024).map(|i| i as f32 * 0.001).collect();
let ch1: Vec<f32> = (0..1024).map(|i| -(i as f32) * 0.001).collect();
let planes = [ch0.as_ptr(), ch1.as_ptr()];
let mut w = unsafe { oakaudio_waveform_init() };
assert!(!w.ctx.is_null());
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(w, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 1024, 48000, 0, 1) },
0
);
// One summary point is produced for any queried window; a 1/1024 s
// window (one 1024-rate mipmap point ~ 46.875 source samples) must
// bracket a narrower range than a 1/64 s window (~750 samples).
let mut fine = [MinMax { min: 0.0, max: 0.0 }; 2];
let fine_points = unsafe {
oakaudio_waveform_get_summary(w, 0, 1, 1, 1024, fine.as_mut_ptr(), 2)
};
assert_eq!(fine_points, 1);
assert_eq!(fine[0].min, 0.0);
assert!((fine[0].max - 0.046).abs() < 1e-5, "fine max = {}", fine[0].max);
assert!((fine[1].min + 0.046).abs() < 1e-5, "fine min = {}", fine[1].min);
assert_eq!(fine[1].max, 0.0);
let mut coarse = [MinMax { min: 0.0, max: 0.0 }; 2];
let coarse_points =
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 64, coarse.as_mut_ptr(), 2) };
assert_eq!(coarse_points, 1);
assert_eq!(coarse[0].min, 0.0);
assert!((coarse[0].max - 0.749).abs() < 1e-5, "coarse max = {}", coarse[0].max);
assert!((coarse[1].min + 0.749).abs() < 1e-5, "coarse min = {}", coarse[1].min);
assert_eq!(coarse[1].max, 0.0);
// Coarser windows necessarily cover more source samples.
assert!(coarse[0].max > fine[0].max);
assert!(coarse[1].min < fine[1].min);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// levelmeter dB conversion: peak_db == 20*log10(peak_linear) and the
/// -200 dB floor match the C++ helpers for the same sample values.
#[test]
fn levelmeter_db_golden() {
let tone = common::planar_from(&[0.5f32; 64], 1);
let refs: Vec<&[f32]> = tone.iter().map(|v| v.as_slice()).collect();
let stats = oakaudio::levelmeter::analyze_sample_buffer(&refs);
let expected_db = 20.0 * 0.5f64.log10();
assert!((stats.channels[0].peak_db - expected_db).abs() < 1e-9);
assert!((stats.channels[0].rms_db - expected_db).abs() < 1e-9);
let silence = common::silence_planar(1, 64);
let refs: Vec<&[f32]> = silence.iter().map(|v| v.as_slice()).collect();
let stats = oakaudio::levelmeter::analyze_sample_buffer(&refs);
assert_eq!(stats.channels[0].peak_db, -200.0);
assert_eq!(stats.channels[0].rms_db, -200.0);
assert_eq!(stats.channels[0].vu_db, -200.0);
}
/// waveformsync envelope offset golden: a reference ramp delayed by two
/// windows in the candidate is recovered as +2 windows with full
/// confidence through the C ABI.
#[test]
fn waveform_sync_offset_golden() {
use oakaudio::ffi::sync::{
oakaudio_sync_estimate_envelope_offset, OffsetResult,
};
let reference: Vec<f64> = (0..10).map(|i| i as f64 * 0.1 + 0.1).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
reference.len() as i32,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
assert!((out.confidence - 1.0).abs() < 1e-9);
}
/// oakaudio_waveform_extract channel cap: a stream claiming more than
/// OAKAUDIO_EXTRACT_MAX_CHANNELS (64) channels is rejected rather than
/// overflowing the internal plane array.
#[test]
fn extract_channel_cap() {
let path = std::env::temp_dir().join(format!(
"oakaudio_cap_{}.wav",
std::process::id()
));
write_wav_header_only(&path, 65, 48000).unwrap();
let mut out_channels = 0i32;
let cpath = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
let r = unsafe {
oakaudio_waveform_extract(
cpath.as_ptr(),
0,
4,
std::ptr::null_mut(),
0,
&mut out_channels,
)
};
assert!(r < 0, "oversized stream must be rejected, got {r}");
std::fs::remove_file(&path).ok();
}
+111
View File
@@ -0,0 +1,111 @@
// 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/>.
//! Handle plumbing contract tests (handle.rs).
use oakaudio::error::{OAKAUDIO_E_FAILED, OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::handle::{
alive_count, get, guard, guard_handle, make_borrowed, make_owned, CHandle,
};
/// make_owned starts at refcount 1; get returns a typed view; dropping the
/// handle decrements to 0.
#[test]
fn owned_lifecycle() {
let before = alive_count();
let mut h = make_owned(42u32);
assert!(!h.is_null());
assert_eq!(alive_count(), before + 1);
// SAFETY: `h` boxes a u32 created above.
let v = unsafe { get::<u32>(&h) }.unwrap();
assert_eq!(*v, 42);
// addref/release round-trip through the function pointers.
let addref = h.addref.unwrap();
let release = h.release.unwrap();
// SAFETY: the ctx was created by make_owned.
unsafe { addref(h.ctx) };
assert_eq!(alive_count(), before + 1); // count unchanged, still 1 box
unsafe { release(h.ctx) };
// Dropping the box (release to zero) decrements the ledger.
let release = h.release.unwrap();
// SAFETY: h.ctx is the box created above; refcount is 1.
unsafe { release(h.ctx) };
h.ctx = std::ptr::null_mut();
assert_eq!(alive_count(), before);
}
/// make_borrowed creates a borrow-only handle whose release frees only the
/// box, never the underlying object.
#[test]
fn borrowed_release() {
let mut value = Box::new(7i32);
// SAFETY: `value` outlives the handle; the ctx points directly at the
// box (not a RefBox), so it is read through the raw pointer.
let mut h = unsafe { make_borrowed(&mut *value) };
assert!(!h.is_null());
// SAFETY: `h.ctx` points at the box created above.
let v = unsafe { &*(h.ctx as *const i32) };
assert_eq!(*v, 7);
// release is a no-op: the box still lives.
let release = h.release.unwrap();
// SAFETY: noop_ref for borrowed handles.
unsafe { release(h.ctx) };
assert_eq!(*value, 7);
h.ctx = std::ptr::null_mut();
}
/// CHandle::null() yields an empty handle; guard over an Ok(()) returns
/// OAKAUDIO_OK (0) and guard_handle over Ok returns a valid handle.
#[test]
fn null_and_guard_ok() {
let null = CHandle::null();
assert!(null.is_null());
assert_eq!(null.abi_version, oakaudio::handle::OAKAUDIO_ABI_VERSION);
assert_eq!(guard(|| Ok(())), OAKAUDIO_OK);
let h = guard_handle(|| Ok(make_owned(1u32)));
assert!(!h.is_null());
// Release it so the alive ledger returns to baseline (tests share the
// process-wide ledger and run in parallel).
// SAFETY: `h.ctx` is the box created above; refcount is 1.
unsafe { (h.release.unwrap())(h.ctx) };
}
/// guard maps an Err to the negative error code without panicking; a
/// panicking body is caught and returns a failure code rather than
/// unwinding across the FFI boundary.
#[test]
fn guard_error_and_panic() {
assert_eq!(guard(|| Err(oakaudio::error::Error::Invalid)), OAKAUDIO_E_INVALID);
assert_eq!(guard(|| Err(oakaudio::error::Error::State)), -60002);
assert_eq!(guard(|| Err(oakaudio::error::Error::Failed("x".to_string()))), -60003);
assert_eq!(guard(|| Err(oakaudio::error::Error::NotFound)), -60004);
assert_eq!(guard(|| Err(oakaudio::error::Error::NoMem)), -60005);
assert_eq!(guard(|| panic!("boom")), OAKAUDIO_E_FAILED);
let h = guard_handle(|| Err(oakaudio::error::Error::Invalid));
assert!(h.is_null());
let h2 = guard_handle(|| panic!("boom"));
assert!(h2.is_null());
}
+158
View File
@@ -0,0 +1,158 @@
// 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/>.
//! AudioLevelMeter contract tests (levelmeter.rs), through the C ABI.
mod common;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_OK};
use oakaudio::ffi::levelmeter::{
oakaudio_levelmeter_analyze, ChannelStats, MeterStats,
};
fn analyze(planes: &[Vec<f32>]) -> (Vec<ChannelStats>, MeterStats) {
let ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut channels: Vec<ChannelStats> = (0..planes.len())
.map(|_| ChannelStats {
peak_linear: 0.0,
peak_db: 0.0,
rms_linear: 0.0,
rms_db: 0.0,
vu_db: 0.0,
})
.collect();
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
let r = unsafe {
oakaudio_levelmeter_analyze(
ptrs.as_ptr(),
planes.len() as i32,
planes.first().map_or(0, |p| p.len()) as i32,
channels.as_mut_ptr(),
channels.len() as i32,
&mut summary,
)
};
assert_eq!(r, OAKAUDIO_OK);
(channels, summary)
}
/// A silence buffer reports silence=1, all-zero linear fields, and dB
/// fields floored at -200.
#[test]
fn silence_analysis() {
let planes = common::silence_planar(2, 64);
let (channels, summary) = analyze(&planes);
assert_eq!(summary.silence, 1);
assert_eq!(summary.max_peak_linear, 0.0);
assert_eq!(summary.integrated_lufs, -200.0);
for ch in &channels {
assert_eq!(ch.peak_linear, 0.0);
assert_eq!(ch.rms_linear, 0.0);
assert_eq!(ch.peak_db, -200.0);
assert_eq!(ch.rms_db, -200.0);
assert_eq!(ch.vu_db, -200.0);
}
}
/// A constant-amplitude tone reports peak_linear == rms_linear == that
/// amplitude (power terms), peak_db matches 20*log10(amp), and silence=0.
#[test]
fn constant_tone_stats() {
let planes = common::planar_from(&[0.5f32; 64], 1);
let (channels, summary) = analyze(&planes);
assert_eq!(summary.silence, 0);
assert!((channels[0].peak_linear - 0.5).abs() < 1e-9);
assert!((channels[0].rms_linear - 0.5).abs() < 1e-9);
let expected_db = 20.0 * 0.5f64.log10();
assert!((channels[0].peak_db - expected_db).abs() < 1e-9);
assert!((channels[0].rms_db - expected_db).abs() < 1e-9);
}
/// A full-scale square wave yields max_peak_linear == 1.0 and a peak_db
/// near 0 dB; per-channel channels array is filled for each channel.
#[test]
fn full_scale_peak() {
let ch0: Vec<f32> = (0..64).map(|i| if i % 2 == 0 { 1.0 } else { -1.0 }).collect();
let ch1: Vec<f32> = (0..64).map(|i| if i % 2 == 0 { -1.0 } else { 1.0 }).collect();
let (channels, summary) = analyze(&[ch0, ch1]);
assert_eq!(summary.max_peak_linear, 1.0);
assert_eq!(summary.silence, 0);
assert!((channels[0].peak_db - 0.0).abs() < 1e-9);
assert!((channels[1].peak_db - 0.0).abs() < 1e-9);
assert!((channels[0].rms_linear - 1.0).abs() < 1e-9);
assert!((channels[1].rms_linear - 1.0).abs() < 1e-9);
}
/// integrated_lufs stays -200 for silence and matches the BS.1770
/// mean-square formula (no K-weighting) for a tone.
#[test]
fn integrated_lufs_silence_vs_tone() {
let silence = common::silence_planar(2, 64);
let (_, summary) = analyze(&silence);
assert_eq!(summary.integrated_lufs, -200.0);
let tone = common::planar_from(&[0.5f32; 64], 2);
let (_, summary) = analyze(&tone);
// mean square over all channels = 0.25; -0.691 + 10*log10(0.25)
let expected = -0.691 + 10.0 * 0.25f64.log10();
assert!((summary.integrated_lufs - expected).abs() < 1e-9);
}
/// channel_count of 0, a NULL planar pointer, or NULL for both outputs
/// returns OAKAUDIO_E_INVALID.
#[test]
fn invalid_input() {
let planes = common::planar_from(&[0.5f32; 8], 1);
let ptr = planes[0].as_ptr();
let mut summary = MeterStats {
max_peak_linear: 0.0,
integrated_lufs: 0.0,
silence: 0,
};
// channel_count 0.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 0, 8, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
// NULL planar.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(std::ptr::null(), 1, 8, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
// Both outputs NULL.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 1, 8, std::ptr::null_mut(), 0, std::ptr::null_mut())
},
OAKAUDIO_E_INVALID
);
// Negative frame count.
assert_eq!(
unsafe {
oakaudio_levelmeter_analyze(&ptr, 1, -1, std::ptr::null_mut(), 0, &mut summary)
},
OAKAUDIO_E_INVALID
);
}
+359
View File
@@ -0,0 +1,359 @@
// 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/>.
//! AudioManager contract tests (manager.rs), through the C ABI. The
//! manager is a process-wide singleton, so every test holds the shared
//! `MANAGER_LOCK`.
mod common;
use std::ffi::c_char;
use common::MANAGER_LOCK;
use oakaudio::bridge::codec::EncodingParams;
use oakaudio::error::{
OAKAUDIO_E_FAILED, OAKAUDIO_E_INVALID, OAKAUDIO_OK,
};
use oakaudio::ffi::manager::{
oakaudio_debug_alive_count, oakaudio_manager_clear_buffered_output,
oakaudio_manager_create_instance, oakaudio_manager_destroy_instance,
oakaudio_manager_find_config_device_by_name_s, oakaudio_manager_find_device_by_name_s,
oakaudio_manager_free, oakaudio_manager_get_input_device,
oakaudio_manager_get_output_device, oakaudio_manager_hard_reset,
oakaudio_manager_instance, oakaudio_manager_push_to_output,
oakaudio_manager_reset_output_clock, oakaudio_manager_seconds,
oakaudio_manager_set_input_device, oakaudio_manager_set_output_device,
oakaudio_manager_set_output_notify_interval, oakaudio_manager_start_recording,
oakaudio_manager_stop_output, oakaudio_manager_stop_recording,
};
fn instance() -> oakaudio::handle::CHandle {
unsafe { oakaudio_manager_instance() }
}
/// Lock the manager singleton for a test. The manager state persists across
/// tests (the `OnceLock` cannot be reset), so a panicked test must not
/// poison the lock for the rest of the binary.
fn lock() -> std::sync::MutexGuard<'static, ()> {
MANAGER_LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
fn encoding_params() -> EncodingParams {
let mut filename = [0 as c_char; 1024];
for (i, b) in b"oakaudio_test.wav\0".iter().enumerate() {
filename[i] = *b as c_char;
}
EncodingParams {
filename,
format: 0,
video_enabled: 0,
video_codec: 0,
video_width: 0,
video_height: 0,
video_time_base_num: 0,
video_time_base_den: 0,
video_pixel_format: 0,
video_interlacing: 0,
video_pixel_aspect_num: 0,
video_pixel_aspect_den: 0,
video_bit_rate: 0,
video_min_bit_rate: 0,
video_max_bit_rate: 0,
video_buffer_size: 0,
video_threads: 0,
video_pix_fmt: [0 as c_char; 64],
video_is_image_sequence: 0,
video_scaling_method: 0,
audio_enabled: 1,
audio_codec: 0,
audio_sample_rate: 48000,
audio_channel_layout: 3,
audio_sample_format: 8,
audio_bit_rate: 128000,
subtitles_enabled: 0,
subtitles_codec: 0,
subtitles_are_sidecar: 0,
subtitles_sidecar_format: 0,
color_transform_output: [0 as c_char; 256],
export_length_num: 0,
export_length_den: 0,
has_custom_range: 0,
custom_range_in_num: 0,
custom_range_in_den: 0,
custom_range_out_num: 0,
custom_range_out_den: 0,
}
}
/// create_instance/destroy_instance toggle the singleton; instance() returns
/// a valid borrowed handle between them and NULL after destroy.
#[test]
fn singleton_lifecycle() {
let _guard = lock();
unsafe { oakaudio_manager_destroy_instance() };
assert!(instance().ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert!(!m.ctx.is_null());
unsafe { oakaudio_manager_free(&mut m.clone()) };
unsafe { oakaudio_manager_destroy_instance() };
assert!(instance().ctx.is_null());
unsafe { oakaudio_manager_create_instance() };
assert!(!instance().ctx.is_null());
}
/// push_to_output accepts raw interleaved bytes and starts the virtual
/// playback clock; without a device it fails with a message in error_buf.
///
/// The virtual device never consumes frames (PortAudio is not bridged), so
/// the clock reads 0.0 rather than advancing.
#[test]
fn push_output_advances_clock() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
// No stream yet: seconds() reports -1.
let mut secs = 0.0f64;
assert_eq!(unsafe { oakaudio_manager_seconds(m, &mut secs) }, OAKAUDIO_OK);
assert_eq!(secs, -1.0);
// Without a device, push fails with a human-readable error. The
// singleton state persists across tests, so pin the no-device state
// explicitly.
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, -1) }, OAKAUDIO_OK);
let samples = vec![0u8; 480 * 2 * 4];
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_push_to_output(
m,
48000,
3,
4,
samples.as_ptr() as *const c_char,
samples.len() as i64,
err.as_mut_ptr(),
err.len() as i32,
)
};
assert_eq!(r, OAKAUDIO_E_FAILED);
assert!(err.iter().any(|&b| b != 0), "error_buf must carry a message");
// After selecting a device the push succeeds and the clock starts at 0.
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, 0) }, OAKAUDIO_OK);
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_push_to_output(
m,
48000,
3,
4,
samples.as_ptr() as *const c_char,
samples.len() as i64,
err.as_mut_ptr(),
err.len() as i32,
)
};
assert_eq!(r, OAKAUDIO_OK);
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, 0.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// set/get output & input device: getters report a device, setters persist
/// it; hard_reset keeps the device indices (it only stops the stream and
/// clears buffers).
#[test]
fn device_selection_roundtrip() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert_eq!(unsafe { oakaudio_manager_set_output_device(m, 42) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_output_device(m) }, 42);
assert_eq!(unsafe { oakaudio_manager_set_input_device(m, 7) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_input_device(m) }, 7);
assert_eq!(unsafe { oakaudio_manager_hard_reset(m) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_get_output_device(m) }, 42);
assert_eq!(unsafe { oakaudio_manager_get_input_device(m) }, 7);
// The stream stopped, so the clock is back at -1.
let mut secs = 0.0f64;
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, -1.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// set_output_notify_interval stores the interval; clear_buffered_output
/// drops queued bytes, stop_output halts the stream, and reset_output_clock
/// restarts the counter.
#[test]
fn output_control_flags() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
assert_eq!(
unsafe { oakaudio_manager_set_output_notify_interval(m, 1024) },
OAKAUDIO_OK
);
assert_eq!(
unsafe { oakaudio_manager_set_output_notify_interval(m, -1) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_manager_clear_buffered_output(m) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_manager_reset_output_clock(m) }, OAKAUDIO_OK);
// Push starts the stream, then stop_output halts it (clock -> -1).
unsafe { oakaudio_manager_set_output_device(m, 0) };
let samples = vec![0u8; 480 * 2 * 4];
assert_eq!(
unsafe {
oakaudio_manager_push_to_output(
m, 48000, 3, 4, samples.as_ptr() as *const c_char,
samples.len() as i64, std::ptr::null_mut(), 0,
)
},
OAKAUDIO_OK
);
assert_eq!(unsafe { oakaudio_manager_stop_output(m) }, OAKAUDIO_OK);
let mut secs = 1.0f64;
unsafe { oakaudio_manager_seconds(m, &mut secs) };
assert_eq!(secs, -1.0);
unsafe { oakaudio_manager_destroy_instance() };
}
/// start_recording with a valid audio-enabled EncodingParams returns
/// OAKAUDIO_OK and a later stop_recording finalizes; NULL params or a
/// disabled audio track return OAKAUDIO_E_INVALID with an error string.
#[test]
fn recording_start_stop() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let m = instance();
unsafe { oakaudio_manager_set_input_device(m, 0) };
let mut err = [0 as c_char; 64];
let params = encoding_params();
assert_eq!(
unsafe { oakaudio_manager_start_recording(m, &params, err.as_mut_ptr(), err.len() as i32) },
OAKAUDIO_OK
);
assert_eq!(unsafe { oakaudio_manager_stop_recording(m) }, OAKAUDIO_OK);
// NULL params is invalid and reports the reason in error_buf.
let mut err = [0 as c_char; 64];
let r = unsafe { oakaudio_manager_start_recording(m, std::ptr::null(), err.as_mut_ptr(), err.len() as i32) };
assert_eq!(r, OAKAUDIO_E_INVALID);
assert!(err.iter().any(|&b| b != 0));
// A disabled audio track is likewise invalid.
let mut disabled = encoding_params();
disabled.audio_enabled = 0;
let mut err = [0 as c_char; 64];
let r = unsafe {
oakaudio_manager_start_recording(m, &disabled, err.as_mut_ptr(), err.len() as i32)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
assert!(err.iter().any(|&b| b != 0));
unsafe { oakaudio_manager_destroy_instance() };
}
/// Device enumeration is not bridged: every name/config lookup falls back to
/// paNoDevice (-1); a NULL name is OAKAUDIO_E_INVALID. The config-backed
/// buffer size/name helpers degrade to their defaults.
#[test]
fn device_name_lookup() {
let _guard = lock();
assert_eq!(
unsafe { oakaudio_manager_find_device_by_name_s(std::ptr::null(), 1) },
OAKAUDIO_E_INVALID
);
let name = c"anything";
assert_eq!(
unsafe { oakaudio_manager_find_device_by_name_s(name.as_ptr(), 1) },
-1
);
assert_eq!(unsafe { oakaudio_manager_find_config_device_by_name_s(1) }, -1);
assert_eq!(unsafe { oakaudio_manager_find_config_device_by_name_s(0) }, -1);
// config::output_buffer_size() reads its default (0) from the stub;
// device_name degrades to the empty string.
assert_eq!(oakaudio::config::output_buffer_size(), 0);
assert!(oakaudio::config::device_name(true).as_c_str().is_empty());
assert!(oakaudio::config::device_name(false).as_c_str().is_empty());
}
/// PreviewAudioDevice pull-side plumbing (read/notify callback/clock) that
/// the manager path only touches indirectly.
#[test]
fn preview_device_pull_side() {
use oakaudio::params::AudioParams;
use oakaudio::previewdevice::PreviewAudioDevice;
let mut dev = PreviewAudioDevice::new();
dev.set_params(AudioParams {
sample_rate: 48000,
channel_layout: 3,
format: oakaudio::params::SampleFormat::F32,
});
assert_eq!(dev.bytes_per_frame(), 8);
let callbacks = std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0));
let cb = std::sync::Arc::clone(&callbacks);
dev.set_notify_callback(move || {
cb.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
});
dev.set_notify_interval(4);
dev.write(&[1u8; 10]);
// Reading 6 bytes crosses a 4-byte notify boundary.
let mut buf = [0u8; 6];
assert_eq!(dev.read(&mut buf), 6);
assert!(callbacks.load(std::sync::atomic::Ordering::Relaxed) >= 1);
assert_eq!(buf, [1u8; 6]);
// Clock accounting.
dev.add_output_frames(3);
assert_eq!(dev.output_frames_consumed(), 3);
dev.reset_output_frames();
assert_eq!(dev.output_frames_consumed(), 0);
dev.clear();
assert_eq!(dev.output_frames_consumed(), 0);
}
/// free(NULL)/free(empty) are no-ops on the manager handle.
#[test]
fn free_null_noop() {
let _guard = lock();
unsafe { oakaudio_manager_create_instance() };
let before = unsafe { oakaudio_debug_alive_count() };
let mut empty = oakaudio::handle::CHandle::null();
unsafe { oakaudio_manager_free(&mut empty) };
unsafe { oakaudio_manager_free(std::ptr::null_mut()) };
assert_eq!(unsafe { oakaudio_debug_alive_count() }, before);
unsafe { oakaudio_manager_destroy_instance() };
}
+224
View File
@@ -0,0 +1,224 @@
// 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/>.
//! 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.
mod common;
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_E_STATE, OAKAUDIO_OK};
use oakaudio::ffi::processor::{
oakaudio_processor_close, oakaudio_processor_convert, oakaudio_processor_flush,
oakaudio_processor_free, oakaudio_processor_init, oakaudio_processor_is_open,
oakaudio_processor_open,
};
/// Stereo f32_p planes of `frames` ramp samples.
fn ramp_planes(frames: usize) -> Vec<Vec<f32>> {
vec![
(0..frames).map(|i| i as f32 * 0.01).collect(),
(0..frames).map(|i| -(i as f32) * 0.01).collect(),
]
}
fn open_identity(h: oakaudio::handle::CHandle) -> i32 {
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 1.0) }
}
/// init yields a valid handle; is_open is false before open and true after;
/// close returns it to closed without error.
#[test]
fn processor_open_isopen_close() {
let mut h = unsafe { oakaudio_processor_init() };
assert!(!h.ctx.is_null());
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
assert_eq!(open_identity(h), OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
assert_eq!(unsafe { oakaudio_processor_close(h) }, OAKAUDIO_OK);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
unsafe { oakaudio_processor_free(&mut h) };
}
/// open with matching in/out rate and format is an identity passthrough:
/// convert returns the same frame count and samples within 1e-6.
#[test]
fn identity_convert_passthrough() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(open_identity(h), OAKAUDIO_OK);
let planes = ramp_planes(32);
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_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, 32);
for ch in 0..2 {
for i in 0..32 {
assert!(
(out[ch][i] - planes[ch][i]).abs() < 1e-6,
"ch{ch}[{i}]: {} vs {}",
out[ch][i],
planes[ch][i]
);
}
}
unsafe { oakaudio_processor_free(&mut h) };
}
/// convert with an output capacity smaller than the produced frames returns
/// the produced count clamped to capacity and fills up to capacity.
#[test]
fn convert_capacity_truncation() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(open_identity(h), OAKAUDIO_OK);
let planes = ramp_planes(32);
let in_ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![vec![9.9f32; 10]; 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(), 10)
};
assert_eq!(n, 10);
for ch in 0..2 {
for i in 0..10 {
assert_eq!(out[ch][i], planes[ch][i]);
}
}
// The graph has already drained; nothing further to pull.
let mut out2 = vec![vec![0f32; 32]; 2];
let mut out2_ptrs: Vec<*mut f32> = out2.iter_mut().map(|p| p.as_mut_ptr()).collect();
let n = unsafe {
oakaudio_processor_convert(h, in_ptrs.as_ptr(), 0, out2_ptrs.as_ptr(), 32)
};
assert_eq!(n, 0);
unsafe { oakaudio_processor_free(&mut h) };
}
/// open with a zero/negative rate or a wrong output format returns
/// OAKAUDIO_E_INVALID and leaves the processor closed; an empty handle is
/// OAKAUDIO_E_INVALID everywhere.
#[test]
fn open_invalid_params() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 0, 3, 4, 48000, 3, 4, 1.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 0, 1.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 0);
let empty = oakaudio::handle::CHandle::null();
assert_eq!(open_identity(empty), OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_is_open(empty) }, OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_close(empty) }, OAKAUDIO_E_INVALID);
assert_eq!(unsafe { oakaudio_processor_flush(empty) }, OAKAUDIO_E_INVALID);
let mut out_ptrs: Vec<*mut f32> = Vec::new();
assert_eq!(
unsafe { oakaudio_processor_convert(empty, std::ptr::null(), 0, out_ptrs.as_ptr(), 0) },
OAKAUDIO_E_INVALID
);
// convert before open is a state error.
assert_eq!(
unsafe { oakaudio_processor_convert(h, std::ptr::null(), 0, out_ptrs.as_ptr(), 0) },
OAKAUDIO_E_STATE
);
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.
#[test]
fn resample_and_flush() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 44100, 3, 4, 22050, 3, 4, 1.0) },
OAKAUDIO_OK
);
let planes = ramp_planes(32);
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_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");
assert_eq!(unsafe { oakaudio_processor_flush(h) }, OAKAUDIO_OK);
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.
#[test]
fn tempo_stretch() {
let mut h = unsafe { oakaudio_processor_init() };
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 2.0) },
OAKAUDIO_OK
);
let planes = ramp_planes(32);
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_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, "tempo 2.0 must halve the frame count");
assert_eq!(unsafe { oakaudio_processor_is_open(h) }, 1);
unsafe { oakaudio_processor_close(h) };
// A non-positive speed is rejected (on a closed processor).
assert_eq!(
unsafe { oakaudio_processor_open(h, 48000, 3, 4, 48000, 3, 4, 0.0) },
OAKAUDIO_E_INVALID
);
assert_eq!(open_identity(h), OAKAUDIO_OK);
// Already open -> state error.
assert_eq!(open_identity(h), OAKAUDIO_E_STATE);
unsafe { oakaudio_processor_free(&mut h) };
}
/// free(NULL)/free(empty) are no-ops.
#[test]
fn free_null_noop() {
let mut h = oakaudio::handle::CHandle::null();
unsafe { oakaudio_processor_free(&mut h) };
unsafe { oakaudio_processor_free(std::ptr::null_mut()) };
}
+433
View File
@@ -0,0 +1,433 @@
// 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/>.
//! AudioSynchronizer + AudioWaveformSync contract tests
//! (synchronizer.rs, waveformsync.rs), through the C ABI.
mod common;
use oakaudio::error::OAKAUDIO_E_INVALID;
use oakaudio::ffi::sync::{
oakaudio_sync_estimate_envelope_offset, oakaudio_sync_estimate_stretch_and_offset,
oakaudio_sync_extract_rms_envelope, oakaudio_sync_place_by_source_time,
oakaudio_sync_place_by_waveform_offset, OffsetResult, SourceClip, StretchOffsetResult,
};
fn clip(source: i64, media_in: i64, has_source: bool) -> SourceClip {
SourceClip {
source_start_time_num: source,
source_start_time_den: 1,
media_in_num: media_in,
media_in_den: 1,
has_source_start_time: has_source as i32,
}
}
/// place_by_source_time: a candidate with matching source time lands at the
/// reference's timeline in point; a source-less candidate (has_source_
/// start_time false) is invalid (no media_in fallback in the C++ logic).
#[test]
fn place_by_source_time_matching() {
let reference = clip(0, 0, true);
let candidate = clip(0, 0, true);
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
5,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
assert_eq!(num, 5);
assert_eq!(den, 1);
assert_eq!(valid, 1);
// A source-less candidate is invalid: valid=0, null rational.
let candidate = clip(0, 0, false);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
5,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
assert_eq!(valid, 0);
assert_eq!(num, 0);
assert_eq!(den, 0);
}
/// place_by_source_time: when source times disagree by a known delta, the
/// candidate's timeline in point shifts by that delta (in seconds).
#[test]
fn place_by_source_time_delta() {
let reference = clip(5, 0, true);
let candidate = clip(12, 0, true);
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
0,
1,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, 0);
// 0 + (12 + 0) - (5 + 0) = 7
assert_eq!(num, 7);
assert_eq!(den, 1);
assert_eq!(valid, 1);
// A zero denominator is rejected up front.
let r = unsafe {
oakaudio_sync_place_by_source_time(
&reference,
&candidate,
0,
0,
&mut num,
&mut den,
&mut valid,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// place_by_waveform_offset converts a sample offset at a sample rate into
/// a timeline-in shift; out_valid is 1 on success and 0 for a null rate.
#[test]
fn place_by_waveform_offset_conversion() {
let (mut num, mut den, mut valid) = (0i64, 0i64, 0i32);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(0, 1, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(num, 1);
assert_eq!(den, 1);
assert_eq!(valid, 1);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 2, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(num, 3);
assert_eq!(den, 2);
assert_eq!(valid, 1);
// A null rate is invalid (valid=0, null rational), and the FFI rejects
// a zero timeline denominator.
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 2, 48000, 0, &mut num, &mut den, &mut valid)
};
assert_eq!(r, 0);
assert_eq!(valid, 0);
assert_eq!(num, 0);
assert_eq!(den, 0);
let r = unsafe {
oakaudio_sync_place_by_waveform_offset(1, 0, 48000, 48000, &mut num, &mut den, &mut valid)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// extract_rms_envelope produces one value per window; a window larger than
/// the input yields a single envelope point.
#[test]
fn extract_rms_envelope_shape() {
let data: Vec<f32> = (0..100).map(|i| i as f32).collect();
let planes = common::planar_from(&data, 2);
let ptrs: Vec<*const f32> = planes.iter().map(|p| p.as_ptr()).collect();
let mut out = vec![0.0f64; 16];
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, out.as_mut_ptr(), 16)
};
assert_eq!(n, 10);
assert!(out.iter().take(10).all(|&v| v > 0.0));
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 200, out.as_mut_ptr(), 16)
};
assert_eq!(n, 1);
// Invalid inputs.
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(std::ptr::null(), 2, 100, 10, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 0, 100, 10, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 0, out.as_mut_ptr(), 16)
},
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, out.as_mut_ptr(), -1)
},
OAKAUDIO_E_INVALID
);
// Two-stage: NULL out returns the required count.
let n = unsafe {
oakaudio_sync_extract_rms_envelope(ptrs.as_ptr(), 2, 100, 10, std::ptr::null_mut(), 0)
};
assert_eq!(n, 10);
}
/// estimate_envelope_offset: for a candidate delayed by N windows relative
/// to the reference, the returned offset is +N windows and valid=1.
#[test]
fn envelope_offset_recovers_delay() {
let reference: Vec<f64> = (0..10).map(|i| i as f64).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
assert!((out.confidence - 1.0).abs() < 1e-9);
}
/// estimate_envelope_offset: windows masked invalid on either side are
/// excluded from correlation; empty masks are treated as all-valid.
#[test]
fn envelope_offset_respects_valid_masks() {
let reference: Vec<f64> = (0..10).map(|i| i as f64).collect();
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
// Only the last reference window is valid -> no lag has >= 2 valid
// overlap windows, so the estimate is invalid.
let mut ref_valid = [1u8; 10];
ref_valid[..9].fill(0);
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
ref_valid.as_ptr(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 0);
assert_eq!(out.confidence, 0.0);
// Fully-valid masks behave like the unmasked call.
let mut valid = [1u8; 10];
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
10,
valid.as_ptr(),
valid.as_ptr(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert_eq!(out.offset_samples, 200);
// NULL arrays / non-positive lengths are invalid.
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
std::ptr::null(),
10,
candidate.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// estimate_stretch_and_offset: a candidate sampled at 2x the reference
/// rate reports rate ~2.0 (>1 = speed up) with a valid=1 result. A
/// non-linear (sine) reference is used — normalized correlation of linear
/// ramps is degenerate (any rate correlates 1.0), but only the true rate
/// resamples the sine back onto the reference exactly.
#[test]
fn stretch_offset_recovers_rate() {
let reference: Vec<f64> = (0..10)
.map(|k| (2.0 * std::f64::consts::PI * 0.7 * k as f64).sin())
.collect();
// Candidate at 2x: even samples are exact, odd samples are midpoints.
let mut candidate = Vec::with_capacity(20);
for k in 0..10 {
candidate.push(reference[k]);
if k + 1 < 10 {
candidate.push((reference[k] + reference[k + 1]) / 2.0);
}
}
let mut out = StretchOffsetResult {
rate: 0.0,
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_stretch_and_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
0.5,
3.0,
0.1,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 1);
assert!((out.rate - 2.0).abs() < 0.15, "rate = {}", out.rate);
assert!(out.confidence > 0.99, "confidence = {}", out.confidence);
// Invalid rate parameters are rejected.
let r = unsafe {
oakaudio_sync_estimate_stretch_and_offset(
reference.as_ptr(),
10,
candidate.as_ptr(),
candidate.len() as i32,
std::ptr::null(),
std::ptr::null(),
100,
10,
0.0,
3.0,
0.1,
&mut out,
)
};
assert_eq!(r, OAKAUDIO_E_INVALID);
}
/// estimate_* on identical silent envelopes yields low/no confidence and
/// valid=0 (no correlation peak).
#[test]
fn silent_inputs_invalid() {
let silence = vec![0.0f64; 10];
let mut out = OffsetResult {
offset_samples: 0,
confidence: 0.0,
valid: 0,
};
let r = unsafe {
oakaudio_sync_estimate_envelope_offset(
silence.as_ptr(),
10,
silence.as_ptr(),
10,
std::ptr::null(),
std::ptr::null(),
100,
10,
&mut out,
)
};
assert_eq!(r, 0);
assert_eq!(out.valid, 0);
assert_eq!(out.confidence, 0.0);
}
/// The crate-level unmasked wrappers (estimate_offset on raw sample
/// buffers, estimate_envelope_offset on envelopes) route to the same
/// correlation core and recover the same delay. A non-monotonic envelope
/// is used — equal-slope linear ramps correlate 1.0 at multiple lags, so
/// only the exact match is unambiguous.
#[test]
fn crate_level_unmasked_wrappers() {
let reference: Vec<f64> = vec![0.0, 0.1, 0.2, 0.9, 0.8, 0.3, 0.4, 0.5, 0.6, 0.7];
let mut candidate = vec![0.0f64; 10];
candidate[2..].copy_from_slice(&reference[..8]);
let env = oakaudio::waveformsync::estimate_envelope_offset(&reference, &candidate, 100, 10);
assert!(env.valid);
assert_eq!(env.offset_samples, 200);
assert!((env.confidence - 1.0).abs() < 1e-9);
// Raw sample buffers: 10 windows of 100 constant-amplitude samples,
// candidate delayed by two windows.
let ref_samples: Vec<f32> = (0..1000).map(|i| reference[i / 100] as f32).collect();
let mut cand_samples = vec![0.0f32; 1000];
cand_samples[200..].copy_from_slice(&ref_samples[..800]);
let raw = oakaudio::waveformsync::estimate_offset(
&[ref_samples.as_slice()],
&[cand_samples.as_slice()],
100,
500,
);
assert!(raw.valid, "offset should be recovered from raw samples");
assert_eq!(raw.offset_samples, 200);
}
+386
View File
@@ -0,0 +1,386 @@
// 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/>.
//! AudioVisualWaveform contract tests (waveform.rs), through the C ABI.
mod common;
use std::ffi::CString;
use common::{pair, write_wav};
use oakaudio::error::{OAKAUDIO_E_INVALID, OAKAUDIO_E_STATE};
use oakaudio::ffi::waveform::{
oakaudio_waveform_extract, oakaudio_waveform_free, oakaudio_waveform_get_channel_count,
oakaudio_waveform_get_summary, oakaudio_waveform_init, oakaudio_waveform_length,
oakaudio_waveform_overwrite_samples, oakaudio_waveform_overwrite_silence,
oakaudio_waveform_overwrite_sums, oakaudio_waveform_re_sum_s, oakaudio_waveform_resize,
oakaudio_waveform_set_channel_count, oakaudio_waveform_sum_samples_s,
oakaudio_waveform_trim_in, oakaudio_waveform_trim_range, MinMax,
};
/// Fill `w` with 100 samples/channel of a 0..0.99 ramp at 100 Hz (1 s).
fn fill_ramp(w: oakaudio::handle::CHandle) {
let ch0: Vec<f32> = (0..100).map(|i| i as f32 * 0.01).collect();
let ch1: Vec<f32> = (0..100).map(|i| -(i as f32) * 0.01).collect();
let planes = [ch0.as_ptr(), ch1.as_ptr()];
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(w, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 100, 100, 0, 1) },
0
);
}
fn summary(w: oakaudio::handle::CHandle, start: (i64, i64), length: (i64, i64), cap: i32) -> Vec<MinMax> {
let mut out = vec![MinMax { min: 0.0, max: 0.0 }; cap as usize * 2];
let n = unsafe {
oakaudio_waveform_get_summary(
w,
start.0,
start.1,
length.0,
length.1,
out.as_mut_ptr(),
cap,
)
};
assert_eq!(n, cap);
out.truncate(cap as usize * 2);
out
}
/// set_channel_count then overwrite_samples writes planar data at the given
/// start; length() reflects the covered span and get_summary returns
/// channel-interleaved min/max pairs.
#[test]
fn overwrite_samples_and_length() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
let (mut num, mut den) = (0i64, 0i64);
assert_eq!(unsafe { oakaudio_waveform_length(w, &mut num, &mut den) }, 0);
assert_eq!(num, 1);
assert_eq!(den, 1);
let out = summary(w, (0, 1), (1, 1), 1);
assert_eq!(out[0].min, 0.0);
assert!((out[0].max - 0.99).abs() < 1e-5, "max = {}", out[0].max);
assert!((out[1].min + 0.99).abs() < 1e-5, "min = {}", out[1].min);
assert_eq!(out[1].max, 0.0);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// get_summary with out_pairs NULL returns the required point count without
/// writing; a too-small capacity returns the same count and leaves the
/// buffer untouched (two-stage contract).
#[test]
fn summary_two_stage_query() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
// NULL out: required count only.
let n = unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 1, std::ptr::null_mut(), 0) };
assert_eq!(n, 1);
// Too-small capacity: same count, buffer untouched.
let mut out = [MinMax { min: -1.0, max: -1.0 }; 2];
let n = unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 1, out.as_mut_ptr(), 0) };
assert_eq!(n, 1);
assert_eq!(out[0].min, -1.0);
assert_eq!(out[0].max, -1.0);
// A zero/negative length is invalid.
assert_eq!(
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 0, 1, std::ptr::null_mut(), 0) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_get_summary(w, 0, 1, 1, 0, std::ptr::null_mut(), 0) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// overwrite_sums copies channel-interleaved pairs from another waveform
/// into a dest range; a 0/1 length copies all of src.
#[test]
fn overwrite_sums_range_copy() {
let mut src = unsafe { oakaudio_waveform_init() };
fill_ramp(src);
let mut dst = unsafe { oakaudio_waveform_init() };
assert_eq!(unsafe { oakaudio_waveform_set_channel_count(dst, 2) }, 0);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_sums(dst, src, 0, 1, 0, 1, 0, 1) },
0
);
// A 0/1 length means "copy everything": dst matches src exactly.
let out = summary(dst, (0, 1), (1, 1), 1);
assert_eq!(out[0].min, 0.0);
assert!((out[0].max - 0.99).abs() < 1e-5, "max = {}", out[0].max);
assert!((out[1].min + 0.99).abs() < 1e-5, "min = {}", out[1].min);
assert_eq!(out[1].max, 0.0);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(dst, &mut num, &mut den) };
assert_eq!(num, 1);
// Empty src handle is invalid.
let empty = oakaudio::handle::CHandle::null();
assert_eq!(
unsafe { oakaudio_waveform_overwrite_sums(dst, empty, 0, 1, 0, 1, 0, 1) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut dst) };
unsafe { oakaudio_waveform_free(&mut src) };
}
/// overwrite_silence zeroes min/max over a range without changing length.
#[test]
fn overwrite_silence() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
assert_eq!(
unsafe { oakaudio_waveform_overwrite_silence(w, 0, 1, 1, 2) },
0
);
// First half is silenced; second half retains the ramp data.
let out = summary(w, (0, 1), (1, 2), 1);
assert_eq!(pair(out[0].min, out[0].max), pair(0.0, 0.0));
assert_eq!(pair(out[1].min, out[1].max), pair(0.0, 0.0));
let out = summary(w, (1, 2), (1, 2), 1);
assert!(out[0].max > 0.5, "second half must keep ramp data, got {:?}", out[0]);
assert!(out[1].min < -0.5);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 1), "overwrite_silence must not change length");
unsafe { oakaudio_waveform_free(&mut w) };
}
/// trim_in/trim_range/resize adjust length and drop or pad data; a negative
/// trim_in prepends silence (C++ semantics).
#[test]
fn trim_and_resize() {
let mut w = unsafe { oakaudio_waveform_init() };
fill_ramp(w);
// Negative trim_in prepends silence: absolute end (length) unchanged.
assert_eq!(unsafe { oakaudio_waveform_trim_in(w, -1, 2) }, 0);
let (mut num, mut den) = (0i64, 0i64);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 1));
// Resize extends to 2 s.
assert_eq!(unsafe { oakaudio_waveform_resize(w, 2, 1) }, 0);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (2, 1));
// trim_range keeps 0.5 s from the (prepended) start.
assert_eq!(unsafe { oakaudio_waveform_trim_range(w, 0, 1, 1, 2) }, 0);
unsafe { oakaudio_waveform_length(w, &mut num, &mut den) };
assert_eq!((num, den), (1, 2));
// A negative resize target or a zero denominator is invalid.
assert_eq!(
unsafe { oakaudio_waveform_resize(w, -1, 2) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_resize(w, 1, 0) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// sum_samples_s reduces planar samples into one min/max pair per channel;
/// re_sum_s merges channel-interleaved entries into one pair per channel.
/// Both match golden vectors from the C++ implementation.
#[test]
fn sum_and_resum_golden() {
let ch0 = [1.0f32, -2.0, 3.0];
let ch1 = [4.0f32, -5.0, 6.0];
let planes = [ch0.as_ptr(), ch1.as_ptr()];
let mut out = [MinMax { min: 0.0, max: 0.0 }; 2];
let r = unsafe {
oakaudio_waveform_sum_samples_s(planes.as_ptr(), 2, 0, 3, out.as_mut_ptr())
};
assert_eq!(r, 0);
assert_eq!(pair(out[0].min, out[0].max), pair(-2.0, 3.0));
assert_eq!(pair(out[1].min, out[1].max), pair(-5.0, 6.0));
// re_sum_s over 4 interleaved entries, 2 channels -> one pair per
// channel merging both points.
let input = [
MinMax { min: 1.0, max: 2.0 },
MinMax { min: 3.0, max: 4.0 },
MinMax { min: 5.0, max: 6.0 },
MinMax { min: 7.0, max: 8.0 },
];
let mut out = [MinMax { min: 0.0, max: 0.0 }; 2];
let r = unsafe { oakaudio_waveform_re_sum_s(input.as_ptr(), 4, 2, out.as_mut_ptr()) };
assert_eq!(r, 0);
assert_eq!(pair(out[0].min, out[0].max), pair(1.0, 6.0));
assert_eq!(pair(out[1].min, out[1].max), pair(3.0, 8.0));
// Invalid arguments.
assert_eq!(
unsafe { oakaudio_waveform_sum_samples_s(planes.as_ptr(), 2, 0, 0, out.as_mut_ptr()) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_re_sum_s(input.as_ptr(), 0, 2, out.as_mut_ptr()) },
OAKAUDIO_E_INVALID
);
}
/// extract decodes a file through the oakcodec decoder C ABI into
/// channel-interleaved pairs; a missing file returns OAKAUDIO_E_NOT_FOUND.
#[test]
fn extract_file_and_notfound() {
let path = std::env::temp_dir().join(format!(
"oakaudio_extract_{}.wav",
std::process::id()
));
// 8 frames of stereo ramp, 48000 Hz.
let mut samples = Vec::with_capacity(16);
for i in 0..8i16 {
samples.push(i * 1000);
samples.push(-(i * 1000));
}
write_wav(&path, 2, 48000, &samples).unwrap();
let cpath = CString::new(path.to_str().unwrap()).unwrap();
// Size query first: NULL out_pairs, the count and channel count still
// come back.
let mut channels = 0i32;
let n = unsafe {
oakaudio_waveform_extract(
cpath.as_ptr(),
0,
4,
std::ptr::null_mut(),
0,
&mut channels,
)
};
assert_eq!(n, 2);
assert_eq!(channels, 2);
// Full extraction: 8 frames / 4 per point = 2 points, 2 pairs each.
let mut pairs = [MinMax { min: 0.0, max: 0.0 }; 4];
let mut channels = 0i32;
let n = unsafe {
oakaudio_waveform_extract(
cpath.as_ptr(),
0,
4,
pairs.as_mut_ptr(),
2,
&mut channels,
)
};
assert_eq!(n, 2);
assert_eq!(channels, 2);
let scale = 32768.0f32;
assert_eq!(pair(pairs[0].min, pairs[0].max), pair(0.0, 3000.0 / scale));
assert_eq!(pair(pairs[1].min, pairs[1].max), pair(-3000.0 / scale, 0.0));
assert_eq!(pair(pairs[2].min, pairs[2].max), pair(4000.0 / scale, 7000.0 / scale));
assert_eq!(pair(pairs[3].min, pairs[3].max), pair(-7000.0 / scale, -4000.0 / scale));
// Missing file -> NOT_FOUND.
let missing = CString::new(
std::env::temp_dir()
.join(format!("oakaudio_missing_{}.wav", std::process::id()))
.to_str()
.unwrap(),
)
.unwrap();
let _ = std::fs::remove_file(std::path::Path::new(missing.to_str().unwrap()));
let r = unsafe {
oakaudio_waveform_extract(
missing.as_ptr(),
0,
4,
std::ptr::null_mut(),
0,
std::ptr::null_mut(),
)
};
assert_eq!(r, -60004);
std::fs::remove_file(&path).ok();
}
/// FFI validation and empty-handle error paths.
#[test]
fn ffi_error_paths() {
let empty = oakaudio::handle::CHandle::null();
assert_eq!(
unsafe { oakaudio_waveform_get_channel_count(empty) },
OAKAUDIO_E_INVALID
);
assert_eq!(
unsafe { oakaudio_waveform_set_channel_count(empty, 2) },
OAKAUDIO_E_INVALID
);
let (mut num, mut den) = (0i64, 0i64);
assert_eq!(
unsafe { oakaudio_waveform_length(empty, &mut num, &mut den) },
OAKAUDIO_E_INVALID
);
// Negative channel count is invalid.
let mut w = unsafe { oakaudio_waveform_init() };
assert_eq!(
unsafe { oakaudio_waveform_set_channel_count(w, -1) },
OAKAUDIO_E_INVALID
);
// overwrite_samples before set_channel_count is a state error.
let data = [0.5f32; 8];
let planes = [data.as_ptr()];
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 8, 48000, 0, 1) },
OAKAUDIO_E_STATE
);
// A zero denominator is rejected.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 8, 48000, 0, 0) },
OAKAUDIO_E_INVALID
);
// A non-positive frame count is invalid.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, planes.as_ptr(), 0, 48000, 0, 1) },
OAKAUDIO_E_INVALID
);
// NULL planes are invalid.
assert_eq!(
unsafe { oakaudio_waveform_overwrite_samples(w, std::ptr::null(), 8, 48000, 0, 1) },
OAKAUDIO_E_INVALID
);
unsafe { oakaudio_waveform_free(&mut w) };
}
/// free(NULL)/free(empty) are no-ops.
#[test]
fn free_null_noop() {
let mut w = oakaudio::handle::CHandle::null();
unsafe { oakaudio_waveform_free(&mut w) };
unsafe { oakaudio_waveform_free(std::ptr::null_mut()) };
}