// 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 . //! 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::(), 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::(), 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::(), 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::(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); }); }