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:
@@ -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 只存/汇总数据 |
|
||||
Generated
+14
@@ -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"
|
||||
@@ -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" }
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(¶ms) {
|
||||
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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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, ¶ms, 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() };
|
||||
}
|
||||
@@ -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()) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()) };
|
||||
}
|
||||
Reference in New Issue
Block a user