The 'host-provided' oakcore_audioparams_* runtime imports dated from the deleted C++ host; the facade is their only caller. The dylib now defines and exports the six symbols itself (repr(C) AudioParams mirror, liboakcore-compatible semantics), -Wl,-undefined,dynamic_lookup is gone, and the Windows DLL undefined-symbol blocker is removed by construction (Windows CI/packaging stays off until a real toolchain verifies links).
1441 lines
42 KiB
Rust
1441 lines
42 KiB
Rust
// 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/>.
|
|
|
|
//! Integration tests for the audio family: the facade exports
|
|
//! `oakengine_audio_*` (src/audio.rs; module C contract
|
|
//! `include/audio/{manager,processor,sync,error}.h`), exercised end to
|
|
//! end against the REAL `oakaudio` crate — no mocks anywhere.
|
|
//!
|
|
//! The facade's 27 exported functions are all covered:
|
|
//!
|
|
//! * Manager (singleton, process-wide — serialized via [`with_manager`]):
|
|
//! create/destroy/instance handle, device accessors, output push/clock,
|
|
//! notify interval, recording start/stop.
|
|
//! * Sync (stateless): envelope offset/stretch correlation, source-time
|
|
//! and waveform-offset timeline placement.
|
|
//! * Waveform extraction (real decode): the two-stage
|
|
//! `oakengine_waveform_extract` contract against real media.
|
|
//! * Processor (refcounted object — serialized via [`with_processor`] so
|
|
//! the module's debug alive counter (`oakaudio_debug_alive_count`) is
|
|
//! deterministic): create/free/open/close/is_open plus the two
|
|
//! documented facade stubs (`convert` returns `OAKENGINE_E_FAILED`,
|
|
//! `output_params` returns NULL — "not backed" in src/audio.rs).
|
|
//!
|
|
//! Legal-path value matrices pin exact results (device indices, rational
|
|
//! placement arithmetic, envelope correlation values); illegal inputs
|
|
//! (NULL pointers, empty handles, out-of-range sizes, garbage enum
|
|
//! values) must return a clean negative code or a documented no-op —
|
|
//! never crash/abort/panic.
|
|
//!
|
|
//! Note: `start_recording` with an input device set drives the REAL
|
|
//! oakcodec FFmpeg encoder (ffmpeg-next), so it writes a real media file
|
|
//! to the system temp dir when the host has the codec; when the host
|
|
//! build cannot create the encoder the call fails with the module's
|
|
//! `OAKAUDIO_E_FAILED` and a diagnostic in `error_buf` — both outcomes
|
|
//! are asserted.
|
|
|
|
use super::common;
|
|
|
|
use std::ffi::{c_int, c_void, CStr, CString};
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
|
|
use crate::audio::{
|
|
oakengine_audio_clear_buffered_output, oakengine_audio_create_instance,
|
|
oakengine_audio_output_levels,
|
|
oakengine_audio_destroy_instance, oakengine_audio_estimate_envelope_offset,
|
|
oakengine_audio_estimate_stretch_and_offset, oakengine_audio_get_input_device,
|
|
oakengine_audio_get_output_device, oakengine_audio_hard_reset, oakengine_audio_manager_handle,
|
|
oakengine_audio_processor_close, oakengine_audio_processor_convert,
|
|
oakengine_audio_processor_create, oakengine_audio_processor_free,
|
|
oakengine_audio_processor_is_open, oakengine_audio_processor_open,
|
|
oakengine_audio_processor_output_params, oakengine_audio_push_to_output,
|
|
oakengine_audio_reset_output_clock, oakengine_audio_set_input_device,
|
|
oakengine_audio_set_output_device, oakengine_audio_set_output_notify_interval,
|
|
oakengine_audio_start_recording, oakengine_audio_stop_output, oakengine_audio_stop_recording,
|
|
oakengine_audio_sync_place_by_source_time, oakengine_audio_sync_place_by_waveform_offset,
|
|
oakengine_waveform_extract, OakAudioSyncPlacement, OakAudioSyncSourceClip,
|
|
OakAudioWaveformOffset, OakAudioWaveformStretchOffset,
|
|
};
|
|
use crate::error::{OAKENGINE_E_FAILED, OAKENGINE_E_INVALID};
|
|
use crate::handle::{CHandle, OakEngineAudioProcessor};
|
|
|
|
/// `OAKAUDIO_E_INVALID` (include/audio/error.h) — module codes pass
|
|
/// through the facade untranslated.
|
|
const AUDIO_E_INVALID: c_int = -60001;
|
|
/// `OAKAUDIO_E_FAILED`.
|
|
const AUDIO_E_FAILED: c_int = -60003;
|
|
/// `OAKAUDIO_E_STATE`.
|
|
const AUDIO_E_STATE: c_int = -60002;
|
|
/// `OAKAUDIO_E_NOT_FOUND` — the waveform extractor's missing-file code.
|
|
const AUDIO_E_NOT_FOUND: c_int = -60004;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Serialization + shared fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Serialize manager-touching tests: the AudioManager singleton is
|
|
/// process-wide and its create/destroy flips a global flag, so all
|
|
/// manager tests take this lock and start from a destroyed state.
|
|
fn with_manager(f: impl FnOnce()) {
|
|
common::with_manager(f)
|
|
}
|
|
|
|
/// Serialize processor tests: each processor is an independent
|
|
/// refcounted object, but the module's debug alive counter is process
|
|
/// global, so count assertions need exclusive access to the family.
|
|
fn with_processor(f: impl FnOnce()) {
|
|
static LOCK: Mutex<()> = Mutex::new(());
|
|
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
f();
|
|
}
|
|
|
|
/// Current number of live refcounted oakaudio objects.
|
|
fn alive() -> c_int {
|
|
crate::stubs::audio::oakaudio_debug_alive_count()
|
|
}
|
|
|
|
/// A borrowed `OakAudioParams*` handle created through the facade's
|
|
/// in-dylib `oakcore_audioparams_*` accessors (tests/common/mod.rs
|
|
/// re-exports them; see `crate::stubs::audio`).
|
|
fn audio_params(rate: c_int, layout: u64, format: c_int) -> *mut c_void {
|
|
common::oakcore_audioparams_create(rate, layout, format)
|
|
}
|
|
|
|
/// Unique recording output path under the system temp dir.
|
|
fn recording_path() -> PathBuf {
|
|
std::env::temp_dir().join(format!("oak-it-audio-rec-{}.wav", std::process::id()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manager — lifecycle and devices (serialized)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Manager lifecycle + device legal matrix + no-instance illegal matrix.
|
|
#[test]
|
|
fn manager_device_lifecycle() {
|
|
with_manager(|| {
|
|
let _ = common::force_link();
|
|
|
|
// Start from a destroyed state.
|
|
assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0);
|
|
|
|
// --- No instance: every function reports a clean error. ---
|
|
assert!(unsafe { oakengine_audio_manager_handle() }.is_null());
|
|
assert_eq!(unsafe { oakengine_audio_get_output_device() }, -1); // paNoDevice
|
|
assert_eq!(unsafe { oakengine_audio_get_input_device() }, -1);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_set_output_device(0) },
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_set_input_device(0) },
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
assert_eq!(unsafe { oakengine_audio_hard_reset() }, OAKENGINE_E_FAILED);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_clear_buffered_output() },
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
assert_eq!(unsafe { oakengine_audio_stop_output() }, OAKENGINE_E_FAILED);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_stop_recording() },
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_reset_output_clock() },
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_set_output_notify_interval(1024) },
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
// push: NULL params is rejected at the facade before the module runs.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
std::ptr::null(),
|
|
c"data".as_ptr(),
|
|
4,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
)
|
|
},
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
// start_recording: NULL params with no instance → the facade's
|
|
// manager check fires first (-3).
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_start_recording(std::ptr::null_mut(), std::ptr::null_mut(), 0)
|
|
},
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
|
|
// --- Lifecycle: create/destroy idempotence and re-create. ---
|
|
assert_eq!(unsafe { oakengine_audio_create_instance() }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_create_instance() }, 0); // no-op when exists
|
|
assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0); // no-op when absent
|
|
assert!(unsafe { oakengine_audio_manager_handle() }.is_null());
|
|
|
|
assert_eq!(unsafe { oakengine_audio_create_instance() }, 0);
|
|
assert!(!unsafe { oakengine_audio_manager_handle() }.is_null());
|
|
|
|
// --- Device accessors: legal matrix. ---
|
|
// The manager singleton retains its device state across
|
|
// destroy/recreate (the OnceLock box is kept; only a flag flips),
|
|
// so pin the devices explicitly instead of assuming fresh defaults.
|
|
assert_eq!(unsafe { oakengine_audio_set_output_device(-1) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_get_output_device() }, -1);
|
|
assert_eq!(unsafe { oakengine_audio_set_input_device(-1) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_get_input_device() }, -1);
|
|
|
|
// The module records any device index (PortAudio enumeration is not
|
|
// bridged); -1 (paNoDevice), 0, a large index and a negative index.
|
|
for device in [-1i64, 0, 999999, -100] {
|
|
assert_eq!(unsafe { oakengine_audio_set_output_device(device) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_get_output_device() }, device);
|
|
}
|
|
// An i64 that does not fit an i32 narrows to 0 (C int narrowing).
|
|
assert_eq!(unsafe { oakengine_audio_set_output_device(1 << 40) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_get_output_device() }, 0);
|
|
|
|
for device in [-1i64, 0, 999999] {
|
|
assert_eq!(unsafe { oakengine_audio_set_input_device(device) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_get_input_device() }, device);
|
|
}
|
|
|
|
// --- Output controls. ---
|
|
assert_eq!(unsafe { oakengine_audio_reset_output_clock() }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_stop_output() }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_clear_buffered_output() }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_hard_reset() }, 0);
|
|
// Notify interval: 0 disables, positive accepted, negative invalid.
|
|
assert_eq!(unsafe { oakengine_audio_set_output_notify_interval(0) }, 0);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_set_output_notify_interval(1024) },
|
|
0
|
|
);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_set_output_notify_interval(-1) },
|
|
AUDIO_E_INVALID
|
|
);
|
|
|
|
// --- push_to_output: legal + illegal matrix. ---
|
|
// NULL params is rejected at the facade (-3) even with an instance.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
std::ptr::null(),
|
|
c"data".as_ptr(),
|
|
4,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
)
|
|
},
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
|
|
// M12 P1: with no explicit device the push still succeeds — the
|
|
// samples buffer for the default output device (unavailable
|
|
// devices keep playback silent instead of failing the push).
|
|
assert_eq!(unsafe { oakengine_audio_set_output_device(-1) }, 0);
|
|
let params = audio_params(48000, 3, 10); // f32 packed, stereo, 48 kHz
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
params as *const c_void,
|
|
c"data".as_ptr(),
|
|
4,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
)
|
|
},
|
|
0
|
|
);
|
|
common::oakcore_audioparams_free(params);
|
|
|
|
// Garbage sample format → E_INVALID.
|
|
let params = audio_params(48000, 3, 99);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
params as *const c_void,
|
|
c"data".as_ptr(),
|
|
4,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
common::oakcore_audioparams_free(params);
|
|
|
|
// Zero sample rate (mock default) → E_INVALID.
|
|
let params = audio_params(0, 3, 10);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
params as *const c_void,
|
|
c"data".as_ptr(),
|
|
4,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
common::oakcore_audioparams_free(params);
|
|
|
|
// NULL samples → E_INVALID.
|
|
let params = audio_params(48000, 3, 10);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
params as *const c_void,
|
|
std::ptr::null(),
|
|
4,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
// Negative byte count → E_INVALID.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
params as *const c_void,
|
|
c"data".as_ptr(),
|
|
-1,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
common::oakcore_audioparams_free(params);
|
|
|
|
// Legal push with a device selected: bytes are queued, error_buf
|
|
// stays untouched on success.
|
|
assert_eq!(unsafe { oakengine_audio_set_output_device(0) }, 0);
|
|
let params = audio_params(48000, 3, 10);
|
|
let mut err = [0i8; 128];
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
params as *const c_void,
|
|
c"data".as_ptr(),
|
|
4,
|
|
err.as_mut_ptr(),
|
|
err.len() as c_int,
|
|
)
|
|
},
|
|
0
|
|
);
|
|
assert_eq!(
|
|
unsafe { *err.as_ptr() },
|
|
0,
|
|
"error_buf untouched on success"
|
|
);
|
|
// A zero-length push is legal (empty queue op).
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_push_to_output(
|
|
params as *const c_void,
|
|
c"".as_ptr(),
|
|
0,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
)
|
|
},
|
|
0
|
|
);
|
|
common::oakcore_audioparams_free(params);
|
|
|
|
assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0);
|
|
});
|
|
}
|
|
|
|
/// Recording: no-device / invalid-params paths and a real encoder start.
|
|
#[test]
|
|
fn manager_recording() {
|
|
with_manager(|| {
|
|
assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_create_instance() }, 0);
|
|
|
|
// NULL params → E_INVALID at the facade.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_start_recording(std::ptr::null_mut(), std::ptr::null_mut(), 0)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
|
|
// audio_enabled == 0 → E_INVALID with a diagnostic.
|
|
let mut params = recording_params(false);
|
|
let mut err = [0i8; 128];
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_start_recording(
|
|
(&mut params as *mut oakcodec::encodingparams::EncodingParams)
|
|
.cast::<c_void>(),
|
|
err.as_mut_ptr(),
|
|
err.len() as c_int,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(),
|
|
"invalid recording parameters"
|
|
);
|
|
|
|
// Valid params but no input device → clean E_FAILED. Pin the input
|
|
// device to paNoDevice first (the retained singleton may hold a
|
|
// device index set by a prior serialized manager test).
|
|
assert_eq!(unsafe { oakengine_audio_set_input_device(-1) }, 0);
|
|
let mut params = recording_params(true);
|
|
let mut err = [0i8; 128];
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_start_recording(
|
|
(&mut params as *mut oakcodec::encodingparams::EncodingParams)
|
|
.cast::<c_void>(),
|
|
err.as_mut_ptr(),
|
|
err.len() as c_int,
|
|
)
|
|
},
|
|
AUDIO_E_FAILED
|
|
);
|
|
assert_eq!(
|
|
unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(),
|
|
"no input device"
|
|
);
|
|
|
|
// With an input device the real oakcodec encoder runs: the module
|
|
// records to a WAV file (pcm_s16le) and reports OAKAUDIO_OK when the
|
|
// host FFmpeg build can create the encoder, or OAKAUDIO_E_FAILED with
|
|
// a diagnostic otherwise. Either way the return is a clean code.
|
|
assert_eq!(unsafe { oakengine_audio_set_input_device(0) }, 0);
|
|
let path = recording_path();
|
|
let _ = std::fs::remove_file(&path);
|
|
let mut params = recording_params(true);
|
|
let mut err = [0i8; 512];
|
|
let rc = unsafe {
|
|
oakengine_audio_start_recording(
|
|
(&mut params as *mut oakcodec::encodingparams::EncodingParams)
|
|
.cast::<c_void>(),
|
|
err.as_mut_ptr(),
|
|
err.len() as c_int,
|
|
)
|
|
};
|
|
// On this host the real oakcodec encoder opens the WAV output and the
|
|
// recording starts (rc == 0, file written); a host without the codec
|
|
// reports OAKAUDIO_E_FAILED with a diagnostic. Either outcome is a
|
|
// clean code with the corresponding side effect.
|
|
match rc {
|
|
0 => assert!(path.exists(), "recording file written"),
|
|
AUDIO_E_FAILED => {
|
|
let msg = unsafe { CStr::from_ptr(err.as_ptr()) }
|
|
.to_str()
|
|
.unwrap_or("");
|
|
assert!(!msg.is_empty(), "encoder failure should write a diagnostic");
|
|
}
|
|
other => panic!("unexpected recording rc {other}"),
|
|
}
|
|
assert!(rc == 0 || rc == AUDIO_E_FAILED, "unexpected rc {rc}");
|
|
if rc == 0 {
|
|
assert!(path.exists(), "recording file written");
|
|
}
|
|
// Recording is stopped unconditionally (idle stop is a no-op), then
|
|
// the file is removed.
|
|
assert_eq!(unsafe { oakengine_audio_stop_recording() }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_stop_recording() }, 0);
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0);
|
|
});
|
|
}
|
|
|
|
/// `oakcodec_encoding_params` with WAV / pcm_s16le and the requested audio
|
|
/// track.
|
|
fn recording_params(audio_enabled: bool) -> oakcodec::encodingparams::EncodingParams {
|
|
let mut p: oakcodec::encodingparams::EncodingParams = unsafe { std::mem::zeroed() };
|
|
let path = recording_path();
|
|
let bytes = path.as_os_str().as_encoded_bytes();
|
|
assert!(bytes.len() < p.filename.len(), "temp path too long");
|
|
p.filename[..bytes.len()].copy_from_slice(bytes);
|
|
p.format = 7; // WAV
|
|
p.audio_enabled = audio_enabled as c_int;
|
|
p.audio_codec = 13; // PCM_S16LE
|
|
p.audio_sample_rate = 48000;
|
|
p.audio_channel_layout = 3; // stereo
|
|
p.audio_sample_format = oakcore_rs::SampleFormat::S16; // packed 16-bit
|
|
p.export_length_num = 1;
|
|
p.export_length_den = 1;
|
|
p
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync — envelope correlation (stateless)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Envelope offset correlation finds the exact shift of a delayed copy.
|
|
#[test]
|
|
fn sync_estimate_envelope_offset() {
|
|
// candidate[k] == reference[k-1]: the candidate lags the reference by
|
|
// one envelope window, so the best lag is +1 window = +window_samples.
|
|
let reference = [0.1_f64, 0.8, 0.3, 0.6, 0.9];
|
|
let candidate = [0.0_f64, 0.1, 0.8, 0.3, 0.6];
|
|
let mut out = OakAudioWaveformOffset {
|
|
offset_samples: 0,
|
|
confidence: 0.0,
|
|
valid: 0,
|
|
};
|
|
let rc = unsafe {
|
|
oakengine_audio_estimate_envelope_offset(
|
|
reference.as_ptr(),
|
|
5,
|
|
candidate.as_ptr(),
|
|
5,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
128,
|
|
1,
|
|
&mut out,
|
|
)
|
|
};
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(out.valid, 1);
|
|
assert_eq!(out.offset_samples, 128);
|
|
assert!((out.confidence - 1.0).abs() < 1e-9);
|
|
|
|
// Explicit all-valid masks (the contract allows NULL = all valid) give
|
|
// the same result.
|
|
let ref_valid = [1u8; 5];
|
|
let cand_valid = [1u8; 5];
|
|
let mut out = OakAudioWaveformOffset {
|
|
offset_samples: 0,
|
|
confidence: 0.0,
|
|
valid: 0,
|
|
};
|
|
let rc = unsafe {
|
|
oakengine_audio_estimate_envelope_offset(
|
|
reference.as_ptr(),
|
|
5,
|
|
candidate.as_ptr(),
|
|
5,
|
|
ref_valid.as_ptr(),
|
|
5,
|
|
cand_valid.as_ptr(),
|
|
5,
|
|
128,
|
|
1,
|
|
&mut out,
|
|
)
|
|
};
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(out.valid, 1);
|
|
assert_eq!(out.offset_samples, 128);
|
|
|
|
// A single constant envelope carries no correlation energy: valid=0 is
|
|
// the documented "no estimate" outcome, rc stays 0.
|
|
let flat = [0.5_f64, 0.5, 0.5, 0.5];
|
|
let mut out = OakAudioWaveformOffset {
|
|
offset_samples: 0,
|
|
confidence: 0.0,
|
|
valid: 0,
|
|
};
|
|
let rc = unsafe {
|
|
oakengine_audio_estimate_envelope_offset(
|
|
flat.as_ptr(),
|
|
4,
|
|
flat.as_ptr(),
|
|
4,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
128,
|
|
4,
|
|
&mut out,
|
|
)
|
|
};
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(out.valid, 0);
|
|
}
|
|
|
|
/// Envelope offset: every NULL/zero/size/garbage argument fails cleanly.
|
|
#[test]
|
|
fn sync_estimate_envelope_offset_invalid() {
|
|
let reference = [0.1_f64, 0.8, 0.3, 0.6, 0.9];
|
|
let mut out = OakAudioWaveformOffset {
|
|
offset_samples: 0,
|
|
confidence: 0.0,
|
|
valid: 0,
|
|
};
|
|
|
|
// NULL pointers are rejected at the facade (-1).
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_estimate_envelope_offset(
|
|
std::ptr::null(),
|
|
5,
|
|
reference.as_ptr(),
|
|
5,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
128,
|
|
4,
|
|
&mut out,
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_estimate_envelope_offset(
|
|
reference.as_ptr(),
|
|
5,
|
|
std::ptr::null(),
|
|
5,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
128,
|
|
4,
|
|
&mut out,
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_estimate_envelope_offset(
|
|
reference.as_ptr(),
|
|
5,
|
|
reference.as_ptr(),
|
|
5,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
128,
|
|
4,
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
|
|
// Zero/negative lengths, zero window, negative max offset → module
|
|
// E_INVALID (-60001).
|
|
for (len, window, max_off) in [(0, 128u64, 4i64), (-1, 128, 4), (5, 0, 4), (5, 128, -1)] {
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_estimate_envelope_offset(
|
|
reference.as_ptr(),
|
|
len,
|
|
reference.as_ptr(),
|
|
len,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
window,
|
|
max_off,
|
|
&mut out,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID,
|
|
"len={len} window={window} max_off={max_off}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Stretch+offset correlation: an identical candidate resolves to rate
|
|
/// 1.0 with zero offset.
|
|
#[test]
|
|
fn sync_estimate_stretch_and_offset() {
|
|
let reference = [0.1_f64, 0.8, 0.3, 0.6, 0.9];
|
|
let mut out = OakAudioWaveformStretchOffset {
|
|
rate: 0.0,
|
|
offset_samples: 0,
|
|
confidence: 0.0,
|
|
valid: 0,
|
|
};
|
|
let rc = unsafe {
|
|
oakengine_audio_estimate_stretch_and_offset(
|
|
reference.as_ptr(),
|
|
5,
|
|
reference.as_ptr(),
|
|
5,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
128,
|
|
1,
|
|
0.5,
|
|
1.5,
|
|
0.25,
|
|
&mut out,
|
|
)
|
|
};
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(out.valid, 1);
|
|
assert_eq!(out.offset_samples, 0);
|
|
assert!((out.rate - 1.0).abs() < 1e-9);
|
|
assert!((out.confidence - 1.0).abs() < 1e-9);
|
|
|
|
// Illegal ranges fail cleanly: NULL out (-1), bad rate bounds (-60001).
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_estimate_stretch_and_offset(
|
|
reference.as_ptr(),
|
|
5,
|
|
reference.as_ptr(),
|
|
5,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
128,
|
|
4,
|
|
0.5,
|
|
1.5,
|
|
0.25,
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
for (min_rate, max_rate, step) in [
|
|
(0.0, 1.5, 0.25),
|
|
(-1.0, 1.5, 0.25),
|
|
(1.5, 1.0, 0.25),
|
|
(0.5, 1.5, 0.0),
|
|
] {
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_estimate_stretch_and_offset(
|
|
reference.as_ptr(),
|
|
5,
|
|
reference.as_ptr(),
|
|
5,
|
|
std::ptr::null(),
|
|
0,
|
|
std::ptr::null(),
|
|
0,
|
|
128,
|
|
4,
|
|
min_rate,
|
|
max_rate,
|
|
step,
|
|
&mut out,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID,
|
|
"min={min_rate} max={max_rate} step={step}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync — timeline placement (stateless)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `place_by_source_time`: timeline_in = reference_timeline_in +
|
|
/// (candidate.source_start + candidate.media_in) -
|
|
/// (reference.source_start + reference.media_in).
|
|
#[test]
|
|
fn sync_place_by_source_time() {
|
|
// Integers: 3 + (5 + 1) - (10 + 0) = -1.
|
|
let reference = OakAudioSyncSourceClip {
|
|
source_start_time_num: 10,
|
|
source_start_time_den: 1,
|
|
media_in_num: 0,
|
|
media_in_den: 1,
|
|
has_source_start_time: 1,
|
|
};
|
|
let candidate = OakAudioSyncSourceClip {
|
|
source_start_time_num: 5,
|
|
source_start_time_den: 1,
|
|
media_in_num: 1,
|
|
media_in_den: 1,
|
|
has_source_start_time: 1,
|
|
};
|
|
let mut out = OakAudioSyncPlacement {
|
|
timeline_in_num: 0,
|
|
timeline_in_den: 1,
|
|
valid: 0,
|
|
};
|
|
let rc = unsafe {
|
|
oakengine_audio_sync_place_by_source_time(&reference, &candidate, 3, 1, &mut out)
|
|
};
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(out.timeline_in_num, -1);
|
|
assert_eq!(out.timeline_in_den, 1);
|
|
assert_eq!(out.valid, 1);
|
|
|
|
// Rationals: 5 + (1 + 1/4) - (1/2 + 0) = 23/4.
|
|
let reference = OakAudioSyncSourceClip {
|
|
source_start_time_num: 1,
|
|
source_start_time_den: 2,
|
|
media_in_num: 0,
|
|
media_in_den: 1,
|
|
has_source_start_time: 1,
|
|
};
|
|
let candidate = OakAudioSyncSourceClip {
|
|
source_start_time_num: 1,
|
|
source_start_time_den: 1,
|
|
media_in_num: 1,
|
|
media_in_den: 4,
|
|
has_source_start_time: 1,
|
|
};
|
|
let mut out = OakAudioSyncPlacement {
|
|
timeline_in_num: 0,
|
|
timeline_in_den: 1,
|
|
valid: 0,
|
|
};
|
|
let rc = unsafe {
|
|
oakengine_audio_sync_place_by_source_time(&reference, &candidate, 5, 1, &mut out)
|
|
};
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(out.timeline_in_num, 23);
|
|
assert_eq!(out.timeline_in_den, 4);
|
|
assert_eq!(out.valid, 1);
|
|
|
|
// A clip without a source start time is documented invalid: rc 0, the
|
|
// placement is 0/0 and valid=0 (not an error).
|
|
let no_source = OakAudioSyncSourceClip {
|
|
source_start_time_num: 0,
|
|
source_start_time_den: 1,
|
|
media_in_num: 0,
|
|
media_in_den: 1,
|
|
has_source_start_time: 0,
|
|
};
|
|
let mut out = OakAudioSyncPlacement {
|
|
timeline_in_num: 0,
|
|
timeline_in_den: 1,
|
|
valid: 0,
|
|
};
|
|
let rc = unsafe {
|
|
oakengine_audio_sync_place_by_source_time(&no_source, &candidate, 3, 1, &mut out)
|
|
};
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(out.valid, 0);
|
|
assert_eq!(out.timeline_in_num, 0);
|
|
assert_eq!(out.timeline_in_den, 0);
|
|
|
|
// Illegal arguments: NULL pointers → -1; zero denominators → -60001.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_sync_place_by_source_time(std::ptr::null(), &candidate, 3, 1, &mut out)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_sync_place_by_source_time(&reference, std::ptr::null(), 3, 1, &mut out)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_sync_place_by_source_time(
|
|
&reference,
|
|
&candidate,
|
|
3,
|
|
1,
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
let bad_den = OakAudioSyncSourceClip {
|
|
source_start_time_num: 1,
|
|
source_start_time_den: 1,
|
|
media_in_num: 0,
|
|
media_in_den: 0, // zero denominator
|
|
has_source_start_time: 1,
|
|
};
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_sync_place_by_source_time(&reference, &bad_den, 3, 1, &mut out,) },
|
|
AUDIO_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_sync_place_by_source_time(
|
|
&reference, &candidate, 3, 0, // zero reference timeline denominator
|
|
&mut out,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
}
|
|
|
|
/// `place_by_waveform_offset`: timeline_in = reference_timeline_in +
|
|
/// candidate_offset_samples / sample_rate.
|
|
#[test]
|
|
fn sync_place_by_waveform_offset() {
|
|
let mut out = OakAudioSyncPlacement {
|
|
timeline_in_num: 0,
|
|
timeline_in_den: 1,
|
|
valid: 0,
|
|
};
|
|
// 48000 samples at 48 kHz = 1 second.
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 1, 48000, 48000, &mut out) },
|
|
0
|
|
);
|
|
assert_eq!(out.timeline_in_num, 1);
|
|
assert_eq!(out.timeline_in_den, 1);
|
|
assert_eq!(out.valid, 1);
|
|
|
|
// Zero offset keeps the reference timeline point (5/2 stays 5/2).
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_sync_place_by_waveform_offset(5, 2, 0, 48000, &mut out) },
|
|
0
|
|
);
|
|
assert_eq!(out.timeline_in_num, 5);
|
|
assert_eq!(out.timeline_in_den, 2);
|
|
assert_eq!(out.valid, 1);
|
|
|
|
// Negative offset: -24000 samples = -0.5 s.
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 1, -24000, 48000, &mut out) },
|
|
0
|
|
);
|
|
assert_eq!(out.timeline_in_num, -1);
|
|
assert_eq!(out.timeline_in_den, 2);
|
|
assert_eq!(out.valid, 1);
|
|
|
|
// 1/2 + 1 s = 3/2.
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_sync_place_by_waveform_offset(1, 2, 48000, 48000, &mut out) },
|
|
0
|
|
);
|
|
assert_eq!(out.timeline_in_num, 3);
|
|
assert_eq!(out.timeline_in_den, 2);
|
|
assert_eq!(out.valid, 1);
|
|
|
|
// Illegal: NULL out → -1; zero reference denominator → -60001;
|
|
// sample_rate <= 0 is documented invalid (rc 0, valid 0).
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_sync_place_by_waveform_offset(0, 1, 0, 48000, std::ptr::null_mut())
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 0, 0, 48000, &mut out) },
|
|
AUDIO_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 1, 0, 0, &mut out) },
|
|
0
|
|
);
|
|
assert_eq!(out.valid, 0);
|
|
assert_eq!(out.timeline_in_num, 0);
|
|
assert_eq!(out.timeline_in_den, 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Waveform extraction (real decode, no manager state)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `oakengine_waveform_extract` two-stage contract against real media: the
|
|
/// facade's `oakengine_testmedia_write_clip` writes a clip carrying a
|
|
/// stereo PCM 440 Hz sine, which the extractor decodes through the real
|
|
/// oakcodec decoder. Illegal arguments → facade `OAKENGINE_E_INVALID`; a
|
|
/// missing file passes the module's `OAKAUDIO_E_NOT_FOUND` through; the
|
|
/// size query reports the point count + channel count; the data pass fills
|
|
/// real min/max peaks.
|
|
#[test]
|
|
fn waveform_extract_two_stage_and_validation() {
|
|
// Illegal arguments are rejected by the facade with its own code.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_waveform_extract(
|
|
std::ptr::null(),
|
|
0,
|
|
256,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_waveform_extract(
|
|
c"x.wav".as_ptr(),
|
|
-1, // negative stream index
|
|
256,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_waveform_extract(
|
|
c"x.wav".as_ptr(),
|
|
0,
|
|
0, // non-positive samples per point
|
|
std::ptr::null_mut(),
|
|
0,
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_waveform_extract(
|
|
c"x.wav".as_ptr(),
|
|
0,
|
|
256,
|
|
std::ptr::null_mut(),
|
|
-1, // negative capacity
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
|
|
// A missing file decodes to the module's NOT_FOUND, passed through.
|
|
let missing = std::env::temp_dir().join(format!(
|
|
"oakengine-it-waveform-missing-{}.wav",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_file(&missing);
|
|
let missing_c = CString::new(missing.to_string_lossy().into_owned()).unwrap();
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_waveform_extract(
|
|
missing_c.as_ptr(),
|
|
0,
|
|
256,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
std::ptr::null_mut(),
|
|
)
|
|
},
|
|
AUDIO_E_NOT_FOUND
|
|
);
|
|
|
|
// Real media: the facade test clip (1 s of stereo PCM 440 Hz sine).
|
|
let media = std::env::temp_dir().join(format!(
|
|
"oakengine-it-waveform-{}.mp4",
|
|
std::process::id()
|
|
));
|
|
let media_c = CString::new(media.to_string_lossy().into_owned()).unwrap();
|
|
assert_eq!(
|
|
unsafe { crate::testmedia::oakengine_testmedia_write_clip(media_c.as_ptr(), 64, 64, 10, 10) },
|
|
0,
|
|
"generate real media for the waveform decode"
|
|
);
|
|
|
|
// Size query: point count + channel count, nothing written.
|
|
let mut channels = 0i32;
|
|
let needed = unsafe {
|
|
oakengine_waveform_extract(
|
|
media_c.as_ptr(),
|
|
0,
|
|
256,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
&mut channels,
|
|
)
|
|
};
|
|
assert!(needed > 0, "1 s at 48 kHz must yield points (got {needed})");
|
|
assert_eq!(channels, 2, "the test clip's audio is stereo");
|
|
|
|
// Data pass: `out_pairs` receives `point_count * channel_count`
|
|
// channel-interleaved pairs (capacity is in points), so the buffer is
|
|
// sized for the stereo stream; the return is the point count.
|
|
let channel_count = channels.max(1) as usize;
|
|
let mut raw =
|
|
vec![crate::pods::MinMax { min: 0.0, max: 0.0 }; needed as usize * channel_count];
|
|
let n = unsafe {
|
|
oakengine_waveform_extract(
|
|
media_c.as_ptr(),
|
|
0,
|
|
256,
|
|
raw.as_mut_ptr(),
|
|
needed,
|
|
&mut channels,
|
|
)
|
|
};
|
|
assert_eq!(n, needed);
|
|
// The 440 Hz sine is audible in the first channel: some window reaches a
|
|
// real peak.
|
|
assert!(
|
|
raw.chunks(channel_count).any(|pt| pt[0].max.abs().max(pt[0].min.abs()) > 0.1),
|
|
"the sine tone must show up in the peaks"
|
|
);
|
|
|
|
// A capacity below the point count is a size-only query (the two-stage
|
|
// contract): the full point count is returned and nothing is written.
|
|
let mut two = [crate::pods::MinMax { min: 0.0, max: 0.0 }; 2];
|
|
let n2 = unsafe {
|
|
oakengine_waveform_extract(
|
|
media_c.as_ptr(),
|
|
0,
|
|
256,
|
|
two.as_mut_ptr(),
|
|
2,
|
|
std::ptr::null_mut(),
|
|
)
|
|
};
|
|
assert_eq!(n2, needed);
|
|
|
|
let _ = std::fs::remove_file(&media);
|
|
let _ = std::fs::remove_file(&missing);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Processor — lifecycle, free contracts, validation (serialized)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Create/free round-trip, NULL/empty free, module double-free safety and
|
|
/// the alive-count leak check.
|
|
#[test]
|
|
fn processor_lifecycle_and_free_contracts() {
|
|
with_processor(|| {
|
|
let baseline = alive();
|
|
|
|
// free(NULL) is a no-op.
|
|
unsafe { oakengine_audio_processor_free(std::ptr::null_mut()) };
|
|
assert_eq!(alive(), baseline);
|
|
|
|
// free(empty handle box) is a no-op: a box wrapping CHandle::null
|
|
// has nothing to release. The box must be a real heap box
|
|
// (free_box deallocates it); a stack-allocated wrapper would be
|
|
// deallocated out from under its owner.
|
|
let empty_ptr = crate::handle::box_handle::<OakEngineAudioProcessor>(CHandle::null());
|
|
assert!(!empty_ptr.is_null());
|
|
unsafe { oakengine_audio_processor_free(empty_ptr) };
|
|
assert_eq!(alive(), baseline);
|
|
|
|
// create bumps the counter; free restores it (leak check).
|
|
let p = unsafe { oakengine_audio_processor_create() };
|
|
assert!(!p.is_null());
|
|
assert_eq!(alive(), baseline + 1);
|
|
unsafe { oakengine_audio_processor_free(p) };
|
|
assert_eq!(alive(), baseline);
|
|
|
|
// The module-level free is double-free-safe (ctx is nulled after
|
|
// release); the counter decrements exactly once.
|
|
let mut h = crate::stubs::audio::oakaudio_processor_init();
|
|
assert!(!h.ctx.is_null());
|
|
assert_eq!(alive(), baseline + 1);
|
|
crate::stubs::audio::oakaudio_processor_free(&mut h);
|
|
crate::stubs::audio::oakaudio_processor_free(&mut h); // no-op
|
|
assert!(h.ctx.is_null());
|
|
assert_eq!(alive(), baseline);
|
|
});
|
|
}
|
|
|
|
/// Processor open: validation order and the clean failure of the
|
|
/// environment-bound graph creation.
|
|
#[test]
|
|
fn processor_open_validation() {
|
|
with_processor(|| {
|
|
let p = unsafe { oakengine_audio_processor_create() };
|
|
assert!(!p.is_null());
|
|
|
|
// NULL `to`/`from` params handles → -1 at the facade.
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_processor_open(p, std::ptr::null(), std::ptr::null(), 1.0) },
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
|
|
// Garbage params: zero rates (mock default) → -60001; tempo <= 0 →
|
|
// -60001; non-planar output format → -60001.
|
|
let from0 = audio_params(0, 3, 4);
|
|
let to0 = audio_params(0, 3, 4);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_open(p, from0 as *const c_void, to0 as *const c_void, 1.0)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
common::oakcore_audioparams_free(from0);
|
|
common::oakcore_audioparams_free(to0);
|
|
|
|
let from = audio_params(48000, 3, 4);
|
|
let to = audio_params(48000, 3, 4);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 0.0)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, -1.0)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
common::oakcore_audioparams_free(from);
|
|
common::oakcore_audioparams_free(to);
|
|
|
|
// Output format must be planar f32 (4); f32 packed (10) is invalid.
|
|
let from = audio_params(48000, 3, 4);
|
|
let to_packed = audio_params(48000, 3, 10);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_open(
|
|
p,
|
|
from as *const c_void,
|
|
to_packed as *const c_void,
|
|
1.0,
|
|
)
|
|
},
|
|
AUDIO_E_INVALID
|
|
);
|
|
common::oakcore_audioparams_free(from);
|
|
common::oakcore_audioparams_free(to_packed);
|
|
|
|
// Legal arguments reach the module's graph creation, which now runs
|
|
// a real in-process FFmpeg filter graph (ffmpeg-next): the open
|
|
// succeeds and the processor reports open; a second open is a state
|
|
// error and close shuts it down again.
|
|
let from = audio_params(48000, 3, 4);
|
|
let to = audio_params(48000, 3, 4);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 1.0)
|
|
},
|
|
0
|
|
);
|
|
assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 1);
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 1.0)
|
|
},
|
|
AUDIO_E_STATE
|
|
);
|
|
assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 0);
|
|
common::oakcore_audioparams_free(from);
|
|
common::oakcore_audioparams_free(to);
|
|
|
|
unsafe { oakengine_audio_processor_free(p) };
|
|
});
|
|
}
|
|
|
|
/// is_open/close on NULL, empty and closed handles.
|
|
#[test]
|
|
fn processor_is_open_and_close() {
|
|
with_processor(|| {
|
|
let p = unsafe { oakengine_audio_processor_create() };
|
|
assert!(!p.is_null());
|
|
|
|
// NULL handle: is_open → 0, close → 0 (documented no-ops).
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_processor_is_open(std::ptr::null_mut()) },
|
|
0
|
|
);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_processor_close(std::ptr::null_mut()) },
|
|
0
|
|
);
|
|
|
|
// Empty handle box: -1 (invalid) from both.
|
|
let mut empty_box = OakEngineAudioProcessor {
|
|
handle: CHandle::null(),
|
|
};
|
|
let empty_ptr = &mut empty_box as *mut OakEngineAudioProcessor;
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_processor_is_open(empty_ptr) },
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
assert_eq!(
|
|
unsafe { oakengine_audio_processor_close(empty_ptr) },
|
|
OAKENGINE_E_INVALID
|
|
);
|
|
|
|
// Fresh processor: closed. close on a closed processor is a no-op
|
|
// (0); is_open stays 0.
|
|
assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0);
|
|
assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 0);
|
|
|
|
unsafe { oakengine_audio_processor_free(p) };
|
|
});
|
|
}
|
|
|
|
/// The two documented facade stubs: convert is "not backed" and always
|
|
/// returns E_FAILED; output_params is "not backed" and always returns
|
|
/// NULL. Both must tolerate any pointer.
|
|
#[test]
|
|
fn processor_convert_and_output_params_stubs() {
|
|
with_processor(|| {
|
|
let p = unsafe { oakengine_audio_processor_create() };
|
|
assert!(!p.is_null());
|
|
|
|
let mut in_planes: [*mut f32; 1] = [std::ptr::null_mut()];
|
|
let mut out_data: *const c_void = std::ptr::null();
|
|
let mut out_size: c_int = 0;
|
|
|
|
// NULL handle.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_convert(
|
|
std::ptr::null_mut(),
|
|
in_planes.as_mut_ptr(),
|
|
0,
|
|
&mut out_data,
|
|
&mut out_size,
|
|
)
|
|
},
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
assert!(unsafe { oakengine_audio_processor_output_params(std::ptr::null_mut()) }.is_null());
|
|
|
|
// Valid handle — same documented stub result.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_convert(
|
|
p,
|
|
in_planes.as_mut_ptr(),
|
|
0,
|
|
&mut out_data,
|
|
&mut out_size,
|
|
)
|
|
},
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
assert!(unsafe { oakengine_audio_processor_output_params(p) }.is_null());
|
|
|
|
unsafe { oakengine_audio_processor_free(p) };
|
|
});
|
|
}
|
|
|
|
/// The full open→close cycle runs the module's real in-process FFmpeg
|
|
/// filter graph (ffmpeg-next), so open succeeds under `cargo test`.
|
|
/// `convert` stays the documented facade stub (`OAKENGINE_E_FAILED`; see
|
|
/// [`processor_convert_and_output_params_stubs`]) — the module-level
|
|
/// convert success path is covered by oakaudio's own processor tests.
|
|
#[test]
|
|
fn processor_full_open_convert_cycle() {
|
|
with_processor(|| {
|
|
let p = unsafe { oakengine_audio_processor_create() };
|
|
assert!(!p.is_null());
|
|
let from = audio_params(48000, 3, 4);
|
|
let to = audio_params(48000, 3, 4);
|
|
let rc = unsafe {
|
|
oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 1.0)
|
|
};
|
|
assert_eq!(rc, 0);
|
|
assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 1);
|
|
let mut in_planes: [*mut f32; 2] = [std::ptr::null_mut(); 2];
|
|
let mut out_data: *const c_void = std::ptr::null();
|
|
let mut out_size: c_int = 0;
|
|
// The facade convert is the documented "not backed" stub.
|
|
assert_eq!(
|
|
unsafe {
|
|
oakengine_audio_processor_convert(
|
|
p,
|
|
in_planes.as_mut_ptr(),
|
|
0,
|
|
&mut out_data,
|
|
&mut out_size,
|
|
)
|
|
},
|
|
OAKENGINE_E_FAILED
|
|
);
|
|
assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0);
|
|
common::oakcore_audioparams_free(from);
|
|
common::oakcore_audioparams_free(to);
|
|
unsafe { oakengine_audio_processor_free(p) };
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// oakengine_audio_output_levels
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The output-level meter export: validation, the no-output case, and a
|
|
/// real peak readback over pushed F32 stereo samples.
|
|
#[test]
|
|
fn audio_output_levels() {
|
|
with_manager(|| unsafe {
|
|
// Out-arg validation (facade E_INVALID; no manager needed).
|
|
let mut peaks = [0.0f32; 4];
|
|
assert_eq!(oakengine_audio_output_levels(std::ptr::null_mut(), 4), OAKENGINE_E_INVALID);
|
|
assert_eq!(oakengine_audio_output_levels(peaks.as_mut_ptr(), 0), OAKENGINE_E_INVALID);
|
|
assert_eq!(oakengine_audio_output_levels(peaks.as_mut_ptr(), -1), OAKENGINE_E_INVALID);
|
|
|
|
// Fresh-ish manager, nothing buffered: 0 channels. The Rust
|
|
// singleton survives destroy (the OnceLock cannot be reset; a
|
|
// DESTROYED flag flips instead), so the output buffer from a
|
|
// previous test may still hold samples — clear it explicitly.
|
|
assert_eq!(oakengine_audio_destroy_instance(), 0);
|
|
assert_eq!(oakengine_audio_create_instance(), 0);
|
|
assert_eq!(oakengine_audio_clear_buffered_output(), 0);
|
|
assert_eq!(oakengine_audio_output_levels(peaks.as_mut_ptr(), 4), 0);
|
|
|
|
// Push 480 frames of packed F32 stereo: left ramps to 0.25, right
|
|
// ramps to ~1.0. The levels are the per-channel linear peaks.
|
|
assert_eq!(oakengine_audio_set_output_device(42), 0);
|
|
assert_eq!(oakengine_audio_clear_buffered_output(), 0);
|
|
let frames = 480usize;
|
|
let mut samples = Vec::with_capacity(frames * 2);
|
|
for i in 0..frames {
|
|
let t = i as f32 / frames as f32;
|
|
samples.push(0.25f32 * t);
|
|
samples.push(t);
|
|
}
|
|
let params = audio_params(48000, 0x3, 10); // 10 = packed F32
|
|
let rc = oakengine_audio_push_to_output(
|
|
params as *const c_void,
|
|
samples.as_ptr() as *const std::ffi::c_char,
|
|
(samples.len() * 4) as i64,
|
|
std::ptr::null_mut(),
|
|
0,
|
|
);
|
|
common::oakcore_audioparams_free(params);
|
|
assert_eq!(rc, 0);
|
|
|
|
let n = oakengine_audio_output_levels(peaks.as_mut_ptr(), 4);
|
|
assert_eq!(n, 2);
|
|
let last = (frames - 1) as f32 / frames as f32;
|
|
assert!((peaks[0] - 0.25 * last).abs() < 1e-6, "left peak: {}", peaks[0]);
|
|
assert!((peaks[1] - last).abs() < 1e-6, "right peak: {}", peaks[1]);
|
|
|
|
// Capacity smaller than the channel count truncates the write but
|
|
// still reports the real channel count.
|
|
let mut one = [0.0f32; 1];
|
|
assert_eq!(oakengine_audio_output_levels(one.as_mut_ptr(), 1), 2);
|
|
});
|
|
}
|