Single-lib cleanup: the per-crate src/bridge/ and src/ffi.rs layers are gone (oakundo/oakcommon/oaknode/oaktimeline/oakcodec/oakaudio/ oakrender/oaktask/oakplugin/oakstorage); cross-crate calls are plain Rust, CHandle marshalling shrinks to the oakengine boundary, and tests call the Rust APIs directly (pure C-ABI wrapper tests removed where the domain layer already covers the behavior). exporter.h family implemented: oakengine_export_render (CLI contract), oakengine_export_render_with_params (was a stub), last_error and progress callback; synchronous path reuses task_create_export + start_sync. Fixes on the way: oaktask video ticket self-deadlock, audio params dropped on the export path, codec encoder AAC slicing and H.264 time base. Real-mp4 tests cover both entry points, progress and the illegal-argument matrix. Also: oakstorage session maps null project handles to None (version- info path), configstore test double literal 3.14 -> 3.15 (clippy PI lint), oakaudio output callback scratch buffer + env-aware P1 test, cli media round-trip test uses a generated 16-frame clip (no more minute-long debug runs).
513 lines
18 KiB
Rust
513 lines
18 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/>.
|
|
|
|
//! 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::{Arc, Mutex, MutexGuard, OnceLock};
|
|
use cpal::{Device, DeviceId};
|
|
use cpal::traits::{DeviceTrait, HostTrait};
|
|
use oakcodec::encoder::Encoder;
|
|
use oakcodec::encodingparams::EncodingParams;
|
|
use crate::error::{Error, Result};
|
|
use crate::params::AudioParams;
|
|
use crate::previewdevice::PreviewAudioDevice;
|
|
use crate::error::Error::NotFound;
|
|
|
|
/// `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).
|
|
///
|
|
/// Public since the single-lib unification (the deleted `ffi`/`bridge`
|
|
/// C ABI is gone): the oakengine facade calls the singleton's methods
|
|
/// directly through [`instance`] instead of crossing the old C ABI.
|
|
pub 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 playback clock (shared with the
|
|
/// PortAudio output callback).
|
|
output_buffer: std::sync::Arc<PreviewAudioDevice>,
|
|
/// Whether the output "stream" is running (stand-in for
|
|
/// `Pa_IsStreamActive`).
|
|
output_started: bool,
|
|
/// The real PortAudio output stream (M12 P1; opened lazily on the
|
|
/// first pushed samples).
|
|
output_device_stream: crate::outputdevice::PortAudioOutput,
|
|
/// Active oakcodec recording encoder (None when idle).
|
|
recording: Option<Arc<dyn Encoder>>,
|
|
}
|
|
|
|
// 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: std::sync::Arc::new(PreviewAudioDevice::new()),
|
|
output_started: false,
|
|
output_device_stream: crate::outputdevice::PortAudioOutput::new(),
|
|
recording: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ManagerInner {
|
|
/// 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);
|
|
// The C++ singleton is deleted on destroy; the OnceLock cannot be
|
|
// reset, so the resurrection must at least come back with a fresh
|
|
// playback state (the previous session's output params/buffer would
|
|
// otherwise leak into the next session's meters and clock).
|
|
if let Some(m) = MANAGER.get() {
|
|
let mut inner = m.lock().unwrap_or_else(|e| e.into_inner());
|
|
inner.output_params = None;
|
|
inner.output_buffer.clear();
|
|
inner.output_started = false;
|
|
}
|
|
}
|
|
|
|
/// 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(&self) -> Option<&Self> {
|
|
if DESTROYED.load(Ordering::SeqCst) {
|
|
return None;
|
|
}
|
|
match MANAGER.get() {
|
|
Some(m) => {
|
|
// SAFETY: `m` is the process-wide singleton; borrowed handles do
|
|
// not free it, so it outlives every handle.
|
|
Some(self)
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
/// Bytes between output-notify pulses (0 disables).
|
|
pub fn set_output_notify_interval(&self, bytes: i64) -> Result<()> {
|
|
if bytes < 0 {
|
|
return Err(Box::new(Error::Invalid));
|
|
}
|
|
self.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(
|
|
&mut self,
|
|
params: AudioParams,
|
|
samples: &[u8],
|
|
_error_buf: &mut [u8],
|
|
) -> Result<()> {
|
|
if self.output_params.as_ref() != Some(¶ms) {
|
|
self.output_params = Some(params);
|
|
self.output_buffer.set_params(params);
|
|
// M12 P1: open (or re-open) the real output stream on a format
|
|
// change; device < 0 selects the system default. A stream
|
|
// failure keeps the samples buffered (silent playback) instead
|
|
// of failing the push.
|
|
let device = self.output_device;
|
|
let rate = params.sample_rate;
|
|
let channels = params.channel_count();
|
|
let sink = self.output_buffer.clone();
|
|
let _ = self.output_device_stream.ensure_open(device, rate, channels, sink);
|
|
}
|
|
self.output_buffer.write(samples);
|
|
self.output_started = true;
|
|
Ok(())
|
|
}
|
|
|
|
/// Discard buffered output.
|
|
pub fn clear_buffered_output(&self) -> Result<()> {
|
|
self.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(&mut self) -> Result<()> {
|
|
self.output_started = false;
|
|
self.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, out: &mut f64) -> Result<()> {
|
|
if !self.output_started {
|
|
*out = -1.0;
|
|
return Ok(());
|
|
}
|
|
let rate = self.output_params.map(|p| p.sample_rate).unwrap_or(0);
|
|
if rate <= 0 {
|
|
*out = -1.0;
|
|
return Ok(());
|
|
}
|
|
let secs = self.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) -> Result<()> {
|
|
self.output_buffer.reset_output_frames();
|
|
Ok(())
|
|
}
|
|
|
|
/// Current output device index (`paNoDevice` = -1) or a negative error code.
|
|
pub fn get_output_device(&self) -> Result<i32> {
|
|
Ok(self.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(&mut self, device: i32) -> Result<()> {
|
|
self.output_device = device;
|
|
self.output_started = false;
|
|
self.output_buffer.clear();
|
|
// The stream reopens with the new device on the next push.
|
|
self.output_device_stream.close();
|
|
Ok(())
|
|
}
|
|
|
|
/// Current input device index or a negative error code.
|
|
pub fn get_input_device(&self) -> Result<i32> {
|
|
Ok(self.input_device)
|
|
}
|
|
|
|
/// Set the input device index.
|
|
pub fn set_input_device(&mut self, device: i32) -> Result<()> {
|
|
self.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(&mut self) -> Result<()> {
|
|
self.output_started = false;
|
|
self.output_buffer.clear();
|
|
self.output_device_stream.close();
|
|
Ok(())
|
|
}
|
|
|
|
/// Start recording the input device to a file via the oakcodec encoder
|
|
/// (direct Rust calls; the encoder is a `dyn Encoder` value). 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(
|
|
&mut self,
|
|
params: &EncodingParams,
|
|
_error_buf: &mut [u8],
|
|
) -> Result<()> {
|
|
if self.input_device == PA_NO_DEVICE {
|
|
return Err(Box::new(Error::Failed("no input device".to_string())));
|
|
}
|
|
let encoder = oakcodec::encoder::create_from_params(params)
|
|
.ok_or_else(|| Error::Failed("failed to create encoder for recording".to_string()))?;
|
|
encoder
|
|
.configure(params)
|
|
.map_err(|e| Error::Failed(format!("encoder configure failed: {e:?}")))?;
|
|
if let Err(e) = encoder.open() {
|
|
let detail = encoder.get_error();
|
|
let msg = if detail.is_empty() {
|
|
format!("failed to open encoder for recording: {e:?}")
|
|
} else {
|
|
format!("failed to open encoder for recording: {detail}")
|
|
};
|
|
return Err(Box::new(Error::Failed(msg)));
|
|
}
|
|
self.recording = Some(encoder);
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop recording.
|
|
///
|
|
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:328` (the PortAudio input
|
|
/// stream is not bridged; the encoder is flushed and closed).
|
|
pub fn stop_recording(&mut self) -> Result<()> {
|
|
if let Some(encoder) = self.recording.take() {
|
|
let _ = encoder.flush();
|
|
let _ = encoder.close();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
|
|
|
|
/// Peak level (linear, 0..1 and above) of each channel of the buffered,
|
|
/// not-yet-consumed output, written to `peaks` in channel order.
|
|
/// Returns the channel count (0 when no output is configured or the
|
|
/// layout is unknown). Only packed/planar F32 buffers are analyzed —
|
|
/// other formats report zeroed peaks. The analysis window is the most
|
|
/// recent 8192 frames of the queue.
|
|
///
|
|
/// There is no C++ counterpart (the Qt side metered inside the audio
|
|
/// output callback); with the output callback unbridged this is how the
|
|
/// UI reads levels.
|
|
pub fn output_levels(&self, peaks: &mut [f32]) -> Result<i32> {
|
|
let Some(params) = self.output_params else {
|
|
return Ok(0);
|
|
};
|
|
let channels = params.channel_count();
|
|
if channels <= 0 {
|
|
return Ok(0);
|
|
}
|
|
let n = (channels as usize).min(peaks.len());
|
|
for p in &mut peaks[..n] {
|
|
*p = 0.0;
|
|
}
|
|
use crate::params::SampleFormat;
|
|
let packed = params.format == SampleFormat::F32;
|
|
let planar = params.format == SampleFormat::F32Planar;
|
|
if !packed && !planar {
|
|
return Ok(channels);
|
|
}
|
|
let frames_max = 8192i64;
|
|
let bpf = channels as i64 * 4;
|
|
let mut buf = vec![0u8; (bpf * frames_max) as usize];
|
|
let got = self.output_buffer.peek_tail(&mut buf);
|
|
// Whole frames only; the tail is what we hold, so leading partial
|
|
// bytes (when the queue is not frame-aligned) are dropped.
|
|
let frames = got / bpf;
|
|
if frames == 0 {
|
|
return Ok(channels);
|
|
}
|
|
let bytes = (frames * bpf) as usize;
|
|
let buf = &buf[..bytes];
|
|
let frame_count = frames as usize;
|
|
let channel_count = channels as usize;
|
|
let mut planes: Vec<Vec<f32>> = vec![Vec::with_capacity(frame_count); channel_count];
|
|
if packed {
|
|
for frame in buf.chunks_exact(bpf as usize) {
|
|
for ch in 0..channel_count {
|
|
let b = &frame[ch * 4..ch * 4 + 4];
|
|
planes[ch].push(f32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
|
}
|
|
}
|
|
} else {
|
|
for (ch, plane) in planes.iter_mut().enumerate() {
|
|
let start = ch * frame_count * 4;
|
|
for b in buf[start..start + frame_count * 4].chunks_exact(4) {
|
|
plane.push(f32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
|
}
|
|
}
|
|
}
|
|
let views: Vec<&[f32]> = planes.iter().map(Vec::as_slice).collect();
|
|
let stats = crate::levelmeter::analyze_sample_buffer(&views);
|
|
for (i, p) in peaks[..n].iter_mut().enumerate() {
|
|
*p = stats.channels[i].peak_linear as f32;
|
|
}
|
|
Ok(channels)
|
|
}
|
|
|
|
}
|
|
|
|
/// Lock the process-wide manager singleton (the direct-Rust replacement
|
|
/// for the deleted C ABI's `oakaudio_manager_instance`): `None` when no
|
|
/// instance exists (never created, or destroyed since the last
|
|
/// [`ManagerInner::create_instance`]).
|
|
pub fn instance() -> Option<MutexGuard<'static, ManagerInner>> {
|
|
if DESTROYED.load(Ordering::SeqCst) {
|
|
return None;
|
|
}
|
|
MANAGER
|
|
.get()
|
|
.map(|m| m.lock().unwrap_or_else(|e| e.into_inner()))
|
|
}
|
|
|
|
/// 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) -> Result<Device> {
|
|
let name = crate::config::device_name(is_output_device)?;
|
|
find_device_by_name_s_or_default(&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_or_default(name: &String, _is_output_device: bool) -> Result<Device> {
|
|
let host = cpal::default_host();
|
|
let device = host.devices()?.find(|d| {
|
|
if d.id().is_err(){
|
|
return false;
|
|
}
|
|
if d.supports_output() && d.id().unwrap().id() == name {
|
|
return true;
|
|
}
|
|
false
|
|
});
|
|
if let Some(device) = device {
|
|
Ok(device)
|
|
}
|
|
else{
|
|
if let Some(device) = host.default_output_device() {
|
|
Ok(device)
|
|
}
|
|
else{
|
|
Err(Box::new(Error::NotFound))
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// M12 P1 acceptance: the PortAudio output callback pulls pushed
|
|
/// samples and advances the playback clock. Requires a working audio
|
|
/// session; skips (returns) when PortAudio cannot deliver callbacks
|
|
/// (CI boxes, background/headless macOS sessions where CoreAudio
|
|
/// starts the stream but never runs it).
|
|
#[test]
|
|
fn output_callback_consumes_pushed_samples() {
|
|
// Skip when the audio system cannot actually run a stream: open a
|
|
// silent stream and require at least one callback within 2 s. A
|
|
// device existing is not enough — headless sessions report
|
|
// is_active=true while delivering zero callbacks.
|
|
use std::sync::atomic::AtomicI64 as A;
|
|
static PROBE: A = A::new(0);
|
|
|
|
DESTROYED.store(false, Ordering::SeqCst);
|
|
let manager = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
|
|
|
|
// A 440 Hz sine, 0.2 s at 48 kHz stereo, packed F32.
|
|
let params = AudioParams {
|
|
sample_rate: 48000,
|
|
channel_layout: 0x3,
|
|
format: crate::params::SampleFormat::F32,
|
|
};
|
|
let frames = 9600usize;
|
|
let mut samples = Vec::with_capacity(frames * 2);
|
|
for i in 0..frames {
|
|
let v = (i as f32 * 440.0 * std::f32::consts::TAU / 48000.0).sin() * 0.5;
|
|
samples.push(v);
|
|
samples.push(v);
|
|
}
|
|
let bytes: Vec<u8> = samples
|
|
.iter()
|
|
.flat_map(|s| s.to_le_bytes())
|
|
.collect();
|
|
manager.lock().unwrap().push_to_output(
|
|
params,
|
|
&bytes,
|
|
&mut vec![0u8; 256],
|
|
)
|
|
.expect("push succeeds even without an explicit device");
|
|
|
|
// Give the audio thread time to consume. PortAudio/CoreAudio
|
|
// stream startup can take SECONDS in some environments (audio HAL
|
|
// device probing), so poll with a generous deadline instead of a
|
|
// fixed sleep. The callback advances the clock for EVERY invocation
|
|
// (underrun counts too), so the pushed frames drain within a couple
|
|
// of hundred milliseconds of real audio time once the stream runs.
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
let target = frames as i64 - 1024;
|
|
let mut consumed = 0i64;
|
|
while std::time::Instant::now() < deadline {
|
|
consumed = manager.lock().unwrap().output_buffer.output_frames_consumed();
|
|
if consumed >= target {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
}
|
|
assert!(
|
|
consumed >= target,
|
|
"the output callback must consume the pushed frames (consumed {consumed} of {frames})"
|
|
);
|
|
|
|
manager.lock().unwrap().output_device_stream.close();
|
|
}
|
|
}
|