refactor: drop internal bridge/ffi layers; exporter family lands

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).
This commit is contained in:
2026-08-16 00:33:45 +08:00
parent 2248be8567
commit ab1a2e9c7b
293 changed files with 27826 additions and 90128 deletions
-135
View File
@@ -1,135 +0,0 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! oakcodec C ABI calls (audio encoding + decoding for the manager and
//! waveform extraction) — now direct Rust calls into the oakcodec crate
//! (single-lib unification, see `docs/zh/plans/riir/single-lib.md`).
//!
//! The previous `*mut c_void` handle convention drifted from the real
//! codec C ABI (which uses the shared [`CHandle`]); the wrappers below
//! match the oakcodec ffi signatures exactly.
use std::ffi::{c_char, c_int};
use crate::handle::CHandle;
/// `oakcodec_encoding_params` — single-lib unification: aliases the
/// oakcodec crate's POD (identical layout; only the audio fields are
/// consumed by oakaudio).
pub type EncodingParams = oakcodec::ffi::encoder::oakcodec_encoding_params;
/// `oakcodec_audio_stream_info` — audio stream metadata from probing.
/// Single-lib unification: aliases the oakcodec crate's POD.
pub type AudioStreamInfo = oakcodec::decoder::OakCodecAudioStreamInfo;
/// `oakcodec_encoder_init` — create an encoder for `params`.
pub unsafe fn oakcodec_encoder_init(params: *const EncodingParams) -> CHandle {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_init(params) }
}
/// `oakcodec_encoder_free`.
pub unsafe fn oakcodec_encoder_free(encoder: *mut CHandle) {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_free(encoder) }
}
/// `oakcodec_encoder_open`.
pub unsafe fn oakcodec_encoder_open(encoder: CHandle) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_open(encoder) }
}
/// `oakcodec_encoder_write_audio` — feed interleaved `f32` audio.
pub unsafe fn oakcodec_encoder_write_audio(
encoder: CHandle,
samples: *const f32,
frame_count: c_int,
) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_write_audio(encoder, samples, frame_count) }
}
/// `oakcodec_encoder_flush`.
pub unsafe fn oakcodec_encoder_flush(encoder: CHandle) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_flush(encoder) }
}
/// `oakcodec_encoder_last_error` — copy the last error string into `buf`.
pub unsafe fn oakcodec_encoder_last_error(
encoder: CHandle,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_last_error(encoder, buf, buf_size) }
}
/// `oakcodec_decoder_probe` — create a probe handle for a file.
pub unsafe fn oakcodec_decoder_probe(filename: *const c_char) -> CHandle {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe(filename) }
}
/// `oakcodec_decoder_free`.
pub unsafe fn oakcodec_decoder_free(decoder: *mut CHandle) {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_free(decoder) }
}
/// `oakcodec_decoder_probe_audio_stream_count`.
pub unsafe fn oakcodec_decoder_probe_audio_stream_count(probe: CHandle) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe_audio_stream_count(probe) }
}
/// `oakcodec_decoder_probe_get_audio_stream` — copy audio stream info.
pub unsafe fn oakcodec_decoder_probe_get_audio_stream(
probe: CHandle,
index: c_int,
out: *mut AudioStreamInfo,
) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_probe_get_audio_stream(probe, index, out) }
}
/// `oakcodec_decoder_open` — open stream `stream_index` for decoding.
pub unsafe fn oakcodec_decoder_open(
decoder: CHandle,
filename: *const c_char,
stream_index: c_int,
) -> c_int {
unsafe { oakcodec::ffi::decoder::oakcodec_decoder_open(decoder, filename, stream_index) }
}
/// `oakcodec_decoder_decode_audio` — decode/convert frames into `buf`.
#[allow(clippy::too_many_arguments)]
pub unsafe fn oakcodec_decoder_decode_audio(
decoder: CHandle,
in_num: c_int,
in_den: c_int,
out_num: c_int,
out_den: c_int,
sample_rate: c_int,
channel_layout: u64,
buf: *mut f32,
buf_frames: c_int,
) -> c_int {
unsafe {
oakcodec::ffi::decoder::oakcodec_decoder_decode_audio(
decoder,
in_num,
in_den,
out_num,
out_den,
sample_rate,
channel_layout,
buf,
buf_frames,
)
}
}
-53
View File
@@ -1,53 +0,0 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! oakcommon C ABI calls (config access + ffmpeg format conversion) —
//! now direct Rust calls into the oakcommon crate (single-lib
//! unification, see `docs/zh/plans/riir/single-lib.md`).
use std::ffi::{c_char, c_int};
/// `oakcommon_config_get` — copy a config string value into `buf`
/// (two-stage; returns the required size including NUL).
pub unsafe fn oakcommon_config_get(
group: *const c_char,
key: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
unsafe { oakcommon::ffi::config::oakcommon_config_get(group, key, buf, buf_size) }
}
/// `oakcommon_config_get_int` — read an integer config value with a
/// default.
pub unsafe fn oakcommon_config_get_int(
group: *const c_char,
key: *const c_char,
default: c_int,
) -> c_int {
unsafe { oakcommon::ffi::config::oakcommon_config_get_int(group, key, default) }
}
/// `oakcommon_ffmpegutils_get_ffmpeg_sample_format` — map an ffmpeg
/// sample format enum to the oak core format, or the reverse.
pub unsafe fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(
smp_fmt: c_int,
out: *mut c_int,
) -> c_int {
unsafe {
oakcommon::ffi::ffmpegutils::oakcommon_ffmpegutils_get_ffmpeg_sample_format(smp_fmt, out)
}
}
-21
View File
@@ -1,21 +0,0 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! C ABI imports from other oak modules (signatures mirror the public
//! headers verbatim; resolved at link time).
pub mod codec;
pub mod common;
+15 -35
View File
@@ -17,20 +17,16 @@
//! The `audio_config` namespace from `src/audio/src/configbridge.*`:
//! audio-specific configuration read through the oakcommon C ABI.
use std::error::Error;
use std::ffi::CString;
use std::str::FromStr;
use oakcommon::configstore::*;
/// PortAudio output buffer size in frames; 0 = let PortAudio choose.
///
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:30`
/// (`audio_config::output_buffer_size`).
pub fn output_buffer_size() -> i32 {
unsafe {
crate::bridge::common::oakcommon_config_get_int(
std::ptr::null(),
c"AudioOutputBufferSize".as_ptr(),
0,
)
}
ConfigStore::instance().get_int(None, "AudioOutputBufferSize", 0)
}
/// Name of the configured audio device for `is_output_device`
@@ -39,35 +35,19 @@ pub fn output_buffer_size() -> i32 {
/// `// CPP-PARITY: src/audio/src/configbridge.cpp:36`
/// (`audio_config::device_name`): two-stage size query; `size <= 1`
/// (absent or empty string) yields the empty string.
pub fn device_name(is_output_device: bool) -> CString {
pub fn device_name(is_output_device: bool) -> Result<String, Box<dyn Error>> {
let key = if is_output_device {
c"AudioOutput"
"AudioOutput"
} else {
c"AudioInput"
"AudioInput"
};
unsafe {
let size = crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
std::ptr::null_mut(),
0,
);
if size <= 1 {
// Absent (OAKCOMMON_E_NOT_FOUND) or empty
return CString::default();
}
let mut buf = vec![0u8; size as usize];
if crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
key.as_ptr(),
buf.as_mut_ptr() as *mut std::ffi::c_char,
size,
) < 0
{
return CString::default();
}
// The buffer is NUL-terminated by the callee.
CString::from_vec_with_nul(buf)
.unwrap_or_else(|e| CString::new(e.into_bytes()).unwrap_or_default())
let store = ConfigStore::instance();
let size = i32::from_str(store.get(None, key)?.as_str())?;
if size <= 1 {
// Absent (OAKCOMMON_E_NOT_FOUND) or empty
return Err(Box::new(crate::error::Error::NotFound));
}
let mut buf = vec![0u8; size as usize];
let name = store.get(None, key)?;
Ok(name)
}
+1 -1
View File
@@ -33,7 +33,7 @@ pub const OAKAUDIO_E_NOT_FOUND: i32 = -60004;
pub const OAKAUDIO_E_NOMEM: i32 = -60005;
/// Crate-internal result type; the FFI layer maps it to the codes.
pub type Result<T> = std::result::Result<T, Error>;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
/// Crate-internal error.
#[derive(Debug, thiserror::Error)]
File diff suppressed because it is too large Load Diff
-226
View File
@@ -1,226 +0,0 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Refcounted-handle scaffolding. Same pattern as the oaknode crate
//! (`src/node/rust/src/handle.rs`); intentionally duplicated rather
//! than shared — each module DLL must run its own addref/release code
//! (the function pointers in a handle always point into the DLL that
//! created the object).
//!
//! The `AudioManager` is the one exception: it uses the same `CHandle`
//! layout but with singleton semantics (addref/release no-ops). See
//! `manager.rs` and `include/audio/manager.h`.
//!
//! `// CPP-PARITY: src/audio/c_api/refcounted.h` (RefCounted box,
//! make_handle_in_place, free_handle) and `src/audio/c_api/alive.cpp`
//! (alive ledger behind `oakaudio_debug_alive_count`).
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};
use crate::error::{Error, OAKAUDIO_E_FAILED, OAKAUDIO_OK};
/// ABI version stamped into every handle.
pub const OAKAUDIO_ABI_VERSION: u32 = 1;
/// Live-object ledger behind `oakaudio_debug_alive_count`.
///
/// `// CPP-PARITY: src/audio/c_api/alive.cpp` (`g_alive`).
static ALIVE: AtomicI32 = AtomicI32::new(0);
/// Current number of live refcounted oakaudio objects.
pub fn alive_count() -> i32 {
ALIVE.load(Ordering::Relaxed)
}
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// The shared ABI value-handle type (single-lib unification, see
/// `docs/zh/plans/riir/single-lib.md`): one canonical
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
/// source-compatible. It is `Clone + Copy` (handles cross the C ABI by
/// value) and `Send + Sync` (refcounted, shared across threads).
pub use oakcore_rs::handle::CHandle;
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_addref`).
unsafe extern "C" fn owned_addref<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a `RefBox<T>` created by
// `make_owned`; we only touch it through the reference while it is live.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
b.refs.fetch_add(1, Ordering::Relaxed);
}
}
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`ref_counted_release`):
/// destroys the box at zero and decrements the alive ledger.
unsafe extern "C" fn owned_release<T: Send + 'static>(ctx: *mut std::ffi::c_void) {
// SAFETY: `ctx` is either NULL or points to a live `RefBox<T>` created
// by `make_owned`; the refcount guards against double-free, and the box
// is only reclaimed once the count reaches zero.
if let Some(b) = unsafe { (ctx as *const RefBox<T>).as_ref() } {
if b.refs.fetch_sub(1, Ordering::AcqRel) == 1 {
drop(unsafe { Box::from_raw(ctx as *mut RefBox<T>) });
ALIVE.fetch_sub(1, Ordering::Relaxed);
}
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
let b = Box::new(RefBox {
refs: AtomicU32::new(1),
value,
});
ALIVE.fetch_add(1, Ordering::Relaxed);
CHandle {
ctx: Box::into_raw(b) as *mut std::ffi::c_void,
addref: Some(owned_addref::<T>),
release: Some(owned_release::<T>),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// No-op addref/release for singleton (borrowed) handles.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`singleton_addref` /
/// `singleton_release`).
unsafe extern "C" fn noop_ref(_ctx: *mut std::ffi::c_void) {}
/// Borrowed handle for an object owned elsewhere (addref/release are
/// no-ops; nothing is ever freed through the handle).
///
/// # Safety
/// Caller guarantees `ptr` outlives every derived handle.
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
CHandle {
ctx: ptr as *mut std::ffi::c_void,
addref: Some(noop_ref),
release: Some(noop_ref),
abi_version: OAKAUDIO_ABI_VERSION,
}
}
/// Typed view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow.
let v = unsafe { &(*(h.ctx as *const RefBox<T>)).value };
Some(v)
}
/// Typed mutable view into an owned handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get_mut<T: 'static>(h: &CHandle) -> Option<&mut T> {
if h.ctx.is_null() {
return None;
}
// SAFETY: caller guarantees `h` is a valid owned handle whose ctx points
// to a `RefBox<T>`; the handle stays alive through the returned borrow,
// and the caller must not alias it with other live borrows.
let v = unsafe { &mut (*(h.ctx as *mut RefBox<T>)).value };
Some(v)
}
/// Shared free() body: release the ctx, no-op on NULL/empty handle.
///
/// `// CPP-PARITY: src/audio/c_api/refcounted.h` (`free_handle`).
///
/// # Safety
/// `self_` must be a valid handle pointer or NULL.
pub unsafe fn free_handle(h: *mut CHandle) {
// SAFETY: caller guarantees `h` is a valid handle pointer or NULL.
if let Some(r) = unsafe { h.as_mut() } {
if !r.ctx.is_null() {
if let Some(release) = r.release {
// SAFETY: the release fn belongs to the same DLL and
// accepts the ctx it originally created.
unsafe { release(r.ctx) };
}
r.ctx = std::ptr::null_mut();
}
}
}
/// Panic-catching FFI wrapper for i32-returning exports.
pub fn guard<F: FnOnce() -> crate::error::Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => OAKAUDIO_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> crate::error::Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for i32 value-returning exports (errors map
/// to the negative error code).
pub fn guard_int<F: FnOnce() -> crate::error::Result<i32>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(v)) => v,
Ok(Err(e)) => e.code(),
Err(_) => OAKAUDIO_E_FAILED,
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
/// Copy a human-readable error string into a C buffer (NUL-terminated,
/// truncated to fit). Returns the required size including the NUL.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp` (`write_error`).
pub fn write_error(s: &str, buf: *mut std::ffi::c_char, buf_size: i32) {
if !buf.is_null() && buf_size > 0 {
let bytes = s.as_bytes();
let n = bytes.len().min((buf_size - 1) as usize);
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, n);
*(buf as *mut u8).add(n) = 0;
}
}
}
/// Convenience: map a condition to an Invalid error.
pub fn invalid_if(cond: bool) -> crate::error::Result<()> {
if cond {
Err(Error::Invalid)
} else {
Ok(())
}
}
-3
View File
@@ -30,11 +30,8 @@
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod bridge;
pub mod config;
pub mod error;
pub mod ffi;
pub mod handle;
pub mod levelmeter;
pub mod manager;
pub mod params;
+339 -403
View File
@@ -26,13 +26,15 @@
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock};
use crate::bridge::codec::EncodingParams;
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::handle::{make_borrowed, CHandle};
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).
@@ -46,7 +48,11 @@ static DESTROYED: AtomicBool = AtomicBool::new(false);
/// Manager state (all device/stream fields; PortAudio itself is not bridged,
/// see [`ManagerInner::default`] for the degradations).
struct ManagerInner {
///
/// 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.
@@ -62,8 +68,8 @@ struct ManagerInner {
/// The real PortAudio output stream (M12 P1; opened lazily on the
/// first pushed samples).
output_device_stream: crate::outputdevice::PortAudioOutput,
/// Active oakcodec recording encoder (NULL when idle).
recording: Option<CHandle>,
/// 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
@@ -89,290 +95,308 @@ impl Default for ManagerInner {
}
}
/// Lock the manager behind a handle; `OAKAUDIO_E_STATE` for empty handles.
fn with_instance(h: &CHandle) -> Result<MutexGuard<'static, ManagerInner>> {
if h.is_null() {
return Err(Error::State);
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(())
}
// SAFETY: `instance()` only creates borrowed handles whose ctx points at
// the MANAGER Mutex, which lives in a static for the whole process.
let m: &'static Mutex<ManagerInner> = unsafe { &*(h.ctx as *const Mutex<ManagerInner>) };
Ok(m.lock().unwrap_or_else(|p| p.into_inner()))
}
/// Create the process-wide AudioManager (no-op when it exists). Returns
/// `OAKAUDIO_OK` or `OAKAUDIO_E_NOMEM`.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:73` (C++ allocates with `new`
/// and reports `OAKAUDIO_E_NOMEM` on exception; Rust allocation infallibly
/// panics, so the error code is never produced).
pub fn create_instance() -> Result<()> {
DESTROYED.store(false, Ordering::SeqCst);
let _ = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
Ok(())
}
/// Destroy the process-wide AudioManager (no-op when absent).
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:85` — the C++ singleton is
/// deleted and re-creatable; `OnceLock` cannot be reset, so a `DESTROYED`
/// flag makes [`instance`] return an empty handle (and a later
/// [`create_instance`] resurrects the existing box).
pub fn destroy_instance() {
DESTROYED.store(true, Ordering::SeqCst);
// 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() -> CHandle {
if DESTROYED.load(Ordering::SeqCst) {
return CHandle::null();
}
match MANAGER.get() {
Some(m) => {
// SAFETY: `m` is the process-wide singleton; borrowed handles do
// not free it, so it outlives every handle.
unsafe { make_borrowed(m as *const _ as *mut Mutex<ManagerInner>) }
}
None => CHandle::null(),
}
}
/// Release a manager handle. No-op (singleton), safe on NULL/empty.
///
/// `// CPP-PARITY: src/audio/c_api/manager.cpp:95` — releasing never
/// destroys; just clear the caller's copy.
pub fn free(self_: *mut CHandle) {
unsafe {
if let Some(h) = self_.as_mut() {
h.ctx = std::ptr::null_mut();
/// 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;
}
}
}
/// Bytes between output-notify pulses (0 disables).
pub fn set_output_notify_interval(self_: &CHandle, bytes: i64) -> Result<()> {
if bytes < 0 {
return Err(Error::Invalid);
/// 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,
}
}
let mut m = with_instance(self_)?;
m.output_buffer.set_notify_interval(bytes);
Ok(())
}
/// Push a block of samples to the output device, opening/restarting the
/// stream when the params changed.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:111` — the PortAudio
/// open/start path is not bridged; the buffer is configured and written
/// directly and the "stream" is marked running. `error_buf` is written by
/// the FFI layer from the returned error.
pub fn push_to_output(
self_: &CHandle,
params: AudioParams,
samples: &[u8],
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.output_params.as_ref() != Some(&params) {
m.output_params = Some(params);
m.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 = m.output_device;
let rate = params.sample_rate;
/// 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(&params) {
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();
let sink = m.output_buffer.clone();
let _ = m.output_device_stream.ensure_open(device, rate, channels, sink);
}
m.output_buffer.write(samples);
m.output_started = true;
Ok(())
}
/// Discard buffered output.
pub fn clear_buffered_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_buffer.clear();
Ok(())
}
/// Stop the output stream.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:229` (`stop_output` aborts
/// the stream and clears the buffer).
pub fn stop_output(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
Ok(())
}
/// Seconds of audio consumed by the output device since the last reset,
/// compensated for output latency; negative when no stream is running.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:169` — PortAudio's
/// `outputLatency` is not representable without a live stream, so the buffer
/// clock is used directly (the `max(0, ...)` clamp is kept).
pub fn seconds(self_: &CHandle, out: &mut f64) -> Result<()> {
let m = with_instance(self_)?;
if !m.output_started {
*out = -1.0;
return Ok(());
}
let rate = m.output_params.map(|p| p.sample_rate).unwrap_or(0);
if rate <= 0 {
*out = -1.0;
return Ok(());
}
let secs = m.output_buffer.output_frames_consumed() as f64 / f64::from(rate);
*out = secs.max(0.0);
Ok(())
}
/// Restart the output clock at zero.
pub fn reset_output_clock(self_: &CHandle) -> Result<()> {
let m = with_instance(self_)?;
m.output_buffer.reset_output_frames();
Ok(())
}
/// Current output device index (`paNoDevice` = -1) or a negative error code.
pub fn get_output_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.output_device)
}
/// Set the output device index.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:238` (the device is
/// recorded and the stream closed; PortAudio's index validation and name
/// logging are not bridged).
pub fn set_output_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_device = device;
m.output_started = false;
m.output_buffer.clear();
// The stream reopens with the new device on the next push.
m.output_device_stream.close();
Ok(())
}
/// Current input device index or a negative error code.
pub fn get_input_device(self_: &CHandle) -> Result<i32> {
let m = with_instance(self_)?;
Ok(m.input_device)
}
/// Set the input device index.
pub fn set_input_device(self_: &CHandle, device: i32) -> Result<()> {
let mut m = with_instance(self_)?;
m.input_device = device;
Ok(())
}
/// Close the output stream and re-initialize PortAudio.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:271` (PortAudio terminate/
/// initialize is not bridged; the output side is reset).
pub fn hard_reset(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
m.output_started = false;
m.output_buffer.clear();
m.output_device_stream.close();
Ok(())
}
/// Start recording the input device to a file via the oakcodec encoder C
/// ABI. The input stream is always captured as interleaved f32.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:278` (encoder init/open;
/// the PortAudio input stream is not bridged). On failure the encoder's
/// last-error string is surfaced when available.
pub fn start_recording(
self_: &CHandle,
params: &EncodingParams,
_error_buf: &mut [u8],
) -> Result<()> {
let mut m = with_instance(self_)?;
if m.input_device == PA_NO_DEVICE {
return Err(Error::Failed("no input device".to_string()));
}
eprintln!(
"MANAGER before encoder_init: audio_enabled={} codec={}",
params.audio_enabled, params.audio_codec
);
let mut enc = unsafe { crate::bridge::codec::oakcodec_encoder_init(params) };
eprintln!(
"MANAGER encoder_init null? {} ptr={:p} size={}",
enc.is_null(),
params as *const EncodingParams,
std::mem::size_of::<EncodingParams>()
);
let direct =
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_init(params as *const EncodingParams) };
eprintln!("MANAGER direct init null? {}", direct.is_null());
if !direct.is_null() {
let mut d = direct;
unsafe { oakcodec::ffi::encoder::oakcodec_encoder_free(&mut d) };
}
if enc.is_null() {
return Err(Error::Failed(
"failed to open encoder for recording".to_string(),
));
}
let open_r = unsafe { crate::bridge::codec::oakcodec_encoder_open(enc) };
if open_r != 0 {
let mut buf = [0i8; 512];
let n = unsafe {
crate::bridge::codec::oakcodec_encoder_last_error(
enc,
buf.as_mut_ptr(),
buf.len() as i32,
)
};
let msg = if n > 0 {
let s = buf.split(|c| *c == 0).next().unwrap_or(&[]);
let bytes: Vec<u8> = s.iter().map(|&b| b as u8).collect();
String::from_utf8_lossy(&bytes).into_owned()
} else {
"failed to open encoder for recording".to_string()
};
unsafe { crate::bridge::codec::oakcodec_encoder_free(&mut enc) };
return Err(Error::Failed(msg));
}
m.recording = Some(enc);
Ok(())
}
/// Stop recording.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:328` (the PortAudio input
/// stream is not bridged; the encoder is flushed and freed).
pub fn stop_recording(self_: &CHandle) -> Result<()> {
let mut m = with_instance(self_)?;
if let Some(mut enc) = m.recording.take() {
unsafe {
crate::bridge::codec::oakcodec_encoder_flush(enc);
crate::bridge::codec::oakcodec_encoder_free(&mut enc);
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)
}
Ok(())
}
/// 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
@@ -380,9 +404,9 @@ pub fn stop_recording(self_: &CHandle) -> Result<()> {
/// not initialized.
///
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:404`.
pub fn find_config_device_by_name_s(is_output_device: bool) -> i32 {
let name = crate::config::device_name(is_output_device);
find_device_by_name_s(&name, is_output_device)
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,
@@ -391,82 +415,31 @@ pub fn find_config_device_by_name_s(is_output_device: bool) -> i32 {
/// `// CPP-PARITY: src/audio/src/audiomanager.cpp:410` — PortAudio device
/// enumeration cannot be bridged from this crate, so the result is always
/// `paNoDevice` and the caller falls back to the default device.
pub fn find_device_by_name_s(name: &std::ffi::CStr, _is_output_device: bool) -> i32 {
let _ = name;
PA_NO_DEVICE
}
/// 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_: &CHandle, peaks: &mut [f32]) -> Result<i32> {
let m = with_instance(self_)?;
let Some(params) = m.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 = m.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]]));
}
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;
}
} 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]]));
}
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))
}
}
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)
}
/// Number of live oakaudio reference-counted objects (leak check).
pub fn debug_alive_count() -> i32 {
crate::handle::alive_count()
}
#[cfg(test)]
mod tests {
@@ -485,40 +458,9 @@ mod tests {
// is_active=true while delivering zero callbacks.
use std::sync::atomic::AtomicI64 as A;
static PROBE: A = A::new(0);
let can_play = (|| {
let pa = portaudio::PortAudio::new().ok()?;
let dev = pa.default_output_device().ok()?;
let info = pa.device_info(dev).ok()?;
let params = portaudio::StreamParameters::<f32>::new(
dev,
2,
true,
info.default_low_output_latency,
);
let settings = portaudio::OutputStreamSettings::new(params, 48000.0, 512);
let cb = move |args: portaudio::OutputStreamCallbackArgs<f32>| {
PROBE.fetch_add(args.frames as i64, Ordering::Relaxed);
portaudio::Continue
};
let mut stream = pa.open_non_blocking_stream(settings, cb).ok()?;
stream.start().ok()?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline && PROBE.load(Ordering::Relaxed) == 0 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
let got = PROBE.load(Ordering::Relaxed) > 0;
let _ = stream.stop();
Some(got)
})();
if can_play != Some(true) {
eprintln!("audio session cannot deliver callbacks; skipping");
return;
}
DESTROYED.store(false, Ordering::SeqCst);
let _ = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
let h = instance();
assert!(!h.is_null());
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 {
@@ -537,8 +479,7 @@ mod tests {
.iter()
.flat_map(|s| s.to_le_bytes())
.collect();
push_to_output(
&h,
manager.lock().unwrap().push_to_output(
params,
&bytes,
&mut vec![0u8; 256],
@@ -548,29 +489,24 @@ mod tests {
// 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.
// 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 {
{
let m = with_instance(&h).unwrap();
consumed = m.output_buffer.output_frames_consumed();
}
if consumed > 0 {
consumed = manager.lock().unwrap().output_buffer.output_frames_consumed();
if consumed >= target {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
consumed > 0,
"the output callback must consume pushed frames"
);
assert!(
consumed >= frames as i64 - 1024,
"most pushed frames are consumed: {consumed}"
consumed >= target,
"the output callback must consume the pushed frames (consumed {consumed} of {frames})"
);
let mut m = with_instance(&h).unwrap();
m.output_device_stream.close();
manager.lock().unwrap().output_device_stream.close();
}
}
+117 -63
View File
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! M12 P1: the real audio output device (PortAudio, direct crate call).
//! M12 P1: the real audio output device (cpal, direct crate call).
//!
//! The playback stream's callback pulls interleaved bytes from the
//! shared [`PreviewAudioDevice`] and advances the output clock; underrun
@@ -25,32 +25,38 @@
//! device's mutex (a short critical section; the buffer is drained in
//! whole frames).
use std::cell::RefCell;
use std::sync::Arc;
use portaudio::{
DeviceIndex, OutputStreamCallbackArgs, OutputStreamSettings, StreamParameters, Stream,
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{
BufferSize, Device, Host, SampleFormat, SampleRate, Stream, StreamConfig,
SupportedBufferSize,
};
use crate::previewdevice::PreviewAudioDevice;
/// The default PortAudio frames-per-buffer for the preview stream.
/// The default frames-per-buffer requested for the preview stream
/// (clamped to the device's supported range).
const FRAMES_PER_BUFFER: u32 = 512;
/// An open (or openable) PortAudio output stream.
/// An open (or openable) cpal output stream.
pub struct PortAudioOutput {
/// PortAudio session (created lazily; `None` when unavailable).
pa: Option<portaudio::PortAudio>,
/// Audio host/session (created lazily; `None` when unavailable).
host: Option<Host>,
/// The live stream (None when closed).
stream: Option<Stream<portaudio::NonBlocking, portaudio::Output<f32>>>,
stream: Option<Stream>,
/// The (device, rate, channels) the stream was opened with.
opened: Option<(i32, i32, i32)>,
}
/// Backwards-compatible name for [`PortAudioOutput`].
pub type AudioOutput = PortAudioOutput;
impl PortAudioOutput {
/// Create the device (PortAudio initialized lazily on first use).
/// Create the device (the host is created lazily on first use).
pub fn new() -> PortAudioOutput {
PortAudioOutput {
pa: None,
host: Some(cpal::default_host()),
stream: None,
opened: None,
}
@@ -61,16 +67,17 @@ impl PortAudioOutput {
self.stream.is_some()
}
/// Close the stream (stop + drop).
/// Close the stream (pause + drop).
pub fn close(&mut self) {
if let Some(mut s) = self.stream.take() {
let _ = s.stop();
if let Some(stream) = self.stream.take() {
let _ = stream.pause();
}
self.opened = None;
}
/// Ensure an output stream is open for `(device, rate, channels)` and
/// pulling from `sink`. `device` < 0 selects the system default.
/// pulling from `sink`. `device` < 0 selects the system default output;
/// any other value is an index into the host's output device list.
/// Failures leave the output silent (samples still buffer).
pub fn ensure_open(
&mut self,
@@ -87,70 +94,117 @@ impl PortAudioOutput {
}
self.close();
let pa = match &self.pa {
Some(pa) => pa.clone(),
None => {
let pa = portaudio::PortAudio::new()
.map_err(|e| format!("PortAudio init failed: {e:?}"))?;
self.pa = Some(pa);
match self.pa.as_ref() {
Some(pa) => pa.clone(),
None => unreachable!("just assigned"),
}
}
};
if self.host.is_none(){
self.host = Some(cpal::default_host())
}
let dev = if device >= 0 {
DeviceIndex(device as u32)
} else {
pa.default_output_device()
.map_err(|e| format!("no default output device: {e:?}"))?
};
let info = pa
.device_info(dev)
.map_err(|e| format!("device info failed: {e:?}"))?;
let latency = info.default_low_output_latency;
let params = StreamParameters::<f32>::new(dev, channels, true, latency);
let settings = OutputStreamSettings::<f32>::new(params, rate as f64, FRAMES_PER_BUFFER);
let host = self.host.as_ref().unwrap().clone();
let output_device = resolve_device(&host, device)
.ok_or_else(|| "no output device available".to_string())?;
let config = pick_config(&output_device, rate, channels)?;
// The callback pulls whole frames from the shared device and
// advances the output clock (underrun → silence). `read` locks
// the device internally; no other locks are taken on the audio
// thread. The scratch buffer is allocated once and reused —
// allocating per callback would violate the real-time rule.
let channels_usize = channels.max(1) as usize;
let sink_cb = sink.clone();
let scratch = std::cell::RefCell::new(Vec::<u8>::new());
let callback = move |args: OutputStreamCallbackArgs<f32>| {
let out = args.buffer;
let frames = args.frames;
let total = frames * channels.max(1) as usize;
let scratch = RefCell::new(Vec::<u8>::new());
let callback = move |out: &mut [f32], _info: &cpal::OutputCallbackInfo| {
let total = out.len();
let mut scratch = scratch.borrow_mut();
scratch.resize(total * 4, 0);
let byte_buf = &mut *scratch;
let got = sink_cb.read(byte_buf);
let frames_got = (got as usize) / (channels.max(1) as usize * 4);
// Convert the interleaved f32 bytes in place to a sample
// slice (PortAudio writes f32s directly).
let n_samples = frames_got * channels.max(1) as usize;
for i in 0..n_samples {
let b = &byte_buf[i * 4..i * 4 + 4];
out[i] = f32::from_le_bytes([b[0], b[1], b[2], b[3]]);
let got = sink_cb.read(scratch.as_mut_slice());
let frames_got = (got as usize) / (channels_usize * 4);
// Convert the interleaved f32 bytes in place to the sample
// slice (cpal hands us f32s directly); zero-fill the underrun
// remainder with silence.
let n_samples = frames_got * channels_usize;
for (i, s) in out.iter_mut().enumerate() {
if i < n_samples {
let b = &scratch[i * 4..i * 4 + 4];
*s = f32::from_le_bytes([b[0], b[1], b[2], b[3]]);
} else {
*s = 0.0;
}
}
for s in out.iter_mut().skip(n_samples) {
*s = 0.0; // underrun: silence
}
sink_cb.add_output_frames(frames as i64);
portaudio::Continue
sink_cb.add_output_frames((total / channels_usize) as i64);
};
let err_callback = |err: cpal::Error| {
eprintln!("output stream error: {err}");
};
let mut stream = pa
.open_non_blocking_stream(settings, callback)
.map_err(|e| format!("output stream open failed: {e:?}"))?;
let stream = output_device
.build_output_stream(config, callback, err_callback, None)
.map_err(|e| format!("output stream open failed: {e}"))?;
stream
.start()
.map_err(|e| format!("output stream start failed: {e:?}"))?;
.play()
.map_err(|e| format!("output stream start failed: {e}"))?;
self.stream = Some(stream);
self.opened = Some((device, rate, channels));
Ok(())
}
}
impl Default for PortAudioOutput {
fn default() -> Self {
PortAudioOutput::new()
}
}
/// Resolve a PortAudio-style device index to a cpal device: an index >= 0
/// picks the Nth output device of the host; anything else (notably -1,
/// `paNoDevice`) falls back to the host's default output device.
fn resolve_device(host: &Host, index: i32) -> Option<Device> {
if index >= 0 {
if let Ok(mut devices) = host.output_devices() {
if let Some(device) = devices.nth(index as usize) {
return Some(device);
}
}
}
host.default_output_device()
}
/// Pick an F32 stream config for `(rate, channels)`. The callback always
/// interprets the buffer as interleaved f32 with the requested channel
/// count, so a config with another format or channel count is never used;
/// the sample rate is clamped into the device's supported range. The
/// requested buffer size is honored when it lies inside the supported
/// range.
fn pick_config(device: &Device, rate: i32, channels: i32) -> Result<StreamConfig, String> {
let want_rate = rate as u32;
let want_channels = channels as u16;
if let Ok(mut configs) = device.supported_output_configs() {
while let Some(c) = configs.next() {
if c.sample_format() != SampleFormat::F32 || c.channels() != want_channels {
continue;
}
let rate = want_rate.clamp(c.min_sample_rate(), c.max_sample_rate());
let buffer_size = match c.buffer_size() {
SupportedBufferSize::Range { min, max } => {
BufferSize::Fixed(FRAMES_PER_BUFFER.clamp(*min, *max))
}
SupportedBufferSize::Unknown => BufferSize::Default,
};
return Ok(StreamConfig {
channels: want_channels,
sample_rate: rate,
buffer_size,
});
}
}
let fallback = device
.default_output_config()
.map_err(|e| format!("output device config unavailable: {e}"))?;
if fallback.sample_format() != SampleFormat::F32 || fallback.channels() != want_channels {
return Err(format!(
"output device supports no F32/{want_channels}-channel stream"
));
}
Ok(fallback.config())
}
+208 -222
View File
@@ -32,11 +32,10 @@ use ffmpeg::{ChannelLayout, Error as FfmpegError};
use ffmpeg_next as ffmpeg;
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
use crate::params::{AudioParams, SampleFormat};
/// A closed audio processor (reference count 1; `ctx == NULL` on allocation
/// failure).
/// An audio processor; created closed, configured with
/// [`open`](Processor::open).
pub struct Processor {
inner: Mutex<ProcessorInner>,
}
@@ -54,8 +53,8 @@ struct ProcessorInner {
}
// SAFETY: the filter graph's raw pointers are only dereferenced through the
// FFmpeg API while the mutex is held, so all access is serialized; the
// handle's refcount keeps the box alive.
// FFmpeg API while the processor's mutex is held, so all access is
// serialized.
unsafe impl Send for ProcessorInner {}
impl Default for ProcessorInner {
@@ -136,7 +135,7 @@ fn fix_channel_layout(params: AudioParams) -> AudioParams {
fn build_graph(from: &AudioParams, to: &AudioParams, speed: f64) -> Result<ffmpeg::filter::Graph> {
let in_format = to_ffmpeg_sample_format(from.format);
if in_format == Sample::None {
return Err(Error::Failed("invalid input sample format".to_string()));
return Err(Box::new(Error::Failed("invalid input sample format".to_string())));
}
let abuffer = ffmpeg::filter::find("abuffer")
@@ -196,236 +195,223 @@ fn is_drain(e: &FfmpegError) -> bool {
|| matches!(e, FfmpegError::Other { errno } if *errno == ffmpeg::error::EAGAIN)
}
/// Borrow the processor state behind a handle.
fn get_processor(self_: &CHandle) -> Result<&Processor> {
// SAFETY: every non-empty handle returned by `init` boxes a `Processor`.
unsafe { crate::handle::get::<Processor>(self_) }.ok_or(Error::Invalid)
}
/// Create a closed processor.
pub fn init() -> Result<CHandle> {
Ok(make_owned(Processor {
inner: Mutex::new(ProcessorInner::default()),
}))
}
/// Release one reference to a processor (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Open the resampling/format-conversion graph. `out_format` is accepted for
/// interface completeness but the conversion output is always planar f32.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:43` (validation order:
/// empty handle, already-open state, invalid rates/speed, forced output
/// format) and `src/audio/src/audioprocessor.cpp:82` (graph creation).
pub fn open(self_: &CHandle, from: AudioParams, to: AudioParams, speed: f64) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
if inner.graph.is_some() {
// C++: "tried to open a processor that was already open"
return Err(Error::State);
}
if from.sample_rate <= 0 || to.sample_rate <= 0 || speed <= 0.0 {
return Err(Error::Invalid);
}
// The C ABI delivers planar float output only; force the output format
// stage to f32p (OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == 4).
if to.format != SampleFormat::F32Planar {
return Err(Error::Invalid);
}
let from_fixed = fix_channel_layout(from);
let to_fixed = fix_channel_layout(to);
// C++: "failed to create audio filter graph"
let graph = build_graph(&from_fixed, &to_fixed, speed)?;
inner.graph = Some(graph);
inner.out_frame = ffmpeg::frame::Audio::empty();
inner.from = from_fixed;
inner.to = to_fixed;
Ok(())
}
/// Close the graph (safe when closed; handle must be non-empty).
pub fn close(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
inner.graph = None;
inner.out_frame = ffmpeg::frame::Audio::empty();
Ok(())
}
/// 1 when open, 0 when closed; error for an empty handle.
pub fn is_open(self_: &CHandle) -> Result<bool> {
let p = get_processor(self_)?;
let inner = p.inner.lock().unwrap();
Ok(inner.graph.is_some())
}
/// Push planar float input and pull converted output. Returns the number of
/// output frames written.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:91` (validation, state
/// check, null `out_planar` short-circuit) and
/// `src/audio/src/audioprocessor.cpp:141` (push/pull loop, byte counting).
pub fn convert(
self_: &CHandle,
in_planar: *const *const f32,
in_frame_count: i32,
out_planar: *const *mut f32,
out_capacity_frames: i32,
) -> Result<i32> {
let p = get_processor(self_)?;
let mut guard = p.inner.lock().unwrap();
let inner = &mut *guard;
if inner.graph.is_none() {
return Err(Error::State);
}
if in_frame_count < 0 || out_capacity_frames < 0 || (in_frame_count > 0 && in_planar.is_null())
{
return Err(Error::Invalid);
}
let channels = inner.to.channel_count();
if channels <= 0 {
return Err(Error::State);
}
let from = inner.from;
let graph = inner.graph.as_mut().unwrap();
let out_frame = &mut inner.out_frame;
if in_frame_count > 0 {
// The FFI layer has no way to know the input plane count, so the
// plane pointer array is walked using the input spec recorded at
// `open` (`// CPP-PARITY: src/audio/src/audioprocessor.cpp:141`).
let nb = in_frame_count as usize;
let in_channels = from.channel_count().max(0) as usize;
let layout = channel_layout_from_mask(from.channel_layout);
let mut frame = ffmpeg::frame::Audio::new(to_ffmpeg_sample_format(from.format), nb, layout);
frame.set_rate(from.sample_rate as u32);
let planar = from.format.is_planar();
// `plane_mut::<T>` requires the exact sample type of the frame
// format, so the copy dispatches on the recorded input format.
macro_rules! fill {
($t:ty) => {{
if planar {
for ch in 0..in_channels {
// SAFETY: `in_planar` is non-null here and the FFI
// contract guarantees at least `from.channel_count()`
// entries, each pointing at `nb` samples of the
// recorded input format.
let src = unsafe { *in_planar.add(ch) } as *const $t;
let dst = frame.plane_mut::<$t>(ch);
unsafe { ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), nb) };
}
} else {
// Packed input: a single plane at `in_planar[0]`.
// SAFETY: see above; the plane holds `nb * channels`
// samples.
let src = unsafe { *in_planar } as *const $t;
let dst = frame.plane_mut::<$t>(0);
unsafe { ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), nb * in_channels) };
}
}};
impl Processor {
/// Create a closed processor.
pub fn init() -> Processor {
Processor {
inner: Mutex::new(ProcessorInner::default()),
}
match from.format {
SampleFormat::U8Planar | SampleFormat::U8 => fill!(u8),
SampleFormat::S16Planar | SampleFormat::S16 => fill!(i16),
SampleFormat::S32Planar | SampleFormat::S32 => fill!(i32),
SampleFormat::S64Planar | SampleFormat::S64 => {
// ffmpeg-next's typed plane API has no `i64` impl; copy the
// 8-byte samples through the raw plane pointers.
if planar {
for ch in 0..in_channels {
}
/// Open the resampling/format-conversion graph. `out_format` is accepted
/// for interface completeness but the conversion output is always planar
/// f32.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:43` (validation order:
/// already-open state, invalid rates/speed, forced output format) and
/// `src/audio/src/audioprocessor.cpp:82` (graph creation).
pub fn open(&self, from: AudioParams, to: AudioParams, speed: f64) -> Result<()> {
let mut inner = self.inner.lock().unwrap();
if inner.graph.is_some() {
// C++: "tried to open a processor that was already open"
return Err(Box::from(Error::State));
}
if from.sample_rate <= 0 || to.sample_rate <= 0 || speed <= 0.0 {
return Err(Box::from(Error::Invalid));
}
// The C ABI delivers planar float output only; force the output format
// stage to f32p (OAKAUDIO_PROCESSOR_OUTPUT_FORMAT == 4).
if to.format != SampleFormat::F32Planar {
return Err(Box::from(Error::Invalid));
}
let from_fixed = fix_channel_layout(from);
let to_fixed = fix_channel_layout(to);
// C++: "failed to create audio filter graph"
let graph = build_graph(&from_fixed, &to_fixed, speed)?;
inner.graph = Some(graph);
inner.out_frame = ffmpeg::frame::Audio::empty();
inner.from = from_fixed;
inner.to = to_fixed;
Ok(())
}
/// Close the graph (safe when closed).
pub fn close(&self) -> Result<()> {
let mut inner = self.inner.lock().unwrap();
inner.graph = None;
inner.out_frame = ffmpeg::frame::Audio::empty();
Ok(())
}
/// 1 when open, 0 when closed.
pub fn is_open(&self) -> Result<bool> {
let inner = self.inner.lock().unwrap();
Ok(inner.graph.is_some())
}
/// Push planar float input and pull converted output. Returns the number
/// of output frames written.
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:91` (validation, state
/// check, null `out_planar` short-circuit) and
/// `src/audio/src/audioprocessor.cpp:141` (push/pull loop, byte counting).
pub fn convert(
&self,
in_planar: *const *const f32,
in_frame_count: i32,
out_planar: *const *mut f32,
out_capacity_frames: i32,
) -> Result<i32> {
let mut guard = self.inner.lock().unwrap();
let inner = &mut *guard;
if inner.graph.is_none() {
return Err(Box::from(Error::State));
}
if in_frame_count < 0 || out_capacity_frames < 0 || (in_frame_count > 0 && in_planar.is_null())
{
return Err(Box::from(Error::Invalid));
}
let channels = inner.to.channel_count();
if channels <= 0 {
return Err(Box::from(Error::State));
}
let from = inner.from;
let graph = inner.graph.as_mut().unwrap();
let out_frame = &mut inner.out_frame;
if in_frame_count > 0 {
// The FFI layer has no way to know the input plane count, so the
// plane pointer array is walked using the input spec recorded at
// `open` (`// CPP-PARITY: src/audio/src/audioprocessor.cpp:141`).
let nb = in_frame_count as usize;
let in_channels = from.channel_count().max(0) as usize;
let layout = channel_layout_from_mask(from.channel_layout);
let mut frame = ffmpeg::frame::Audio::new(to_ffmpeg_sample_format(from.format), nb, layout);
frame.set_rate(from.sample_rate as u32);
let planar = from.format.is_planar();
// `plane_mut::<T>` requires the exact sample type of the frame
// format, so the copy dispatches on the recorded input format.
macro_rules! fill {
($t:ty) => {{
if planar {
for ch in 0..in_channels {
// SAFETY: `in_planar` is non-null here and the FFI
// contract guarantees at least `from.channel_count()`
// entries, each pointing at `nb` samples of the
// recorded input format.
let src = unsafe { *in_planar.add(ch) } as *const $t;
let dst = frame.plane_mut::<$t>(ch);
unsafe { ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), nb) };
}
} else {
// Packed input: a single plane at `in_planar[0]`.
// SAFETY: see above; the plane holds `nb * channels`
// samples.
let src = unsafe { *in_planar } as *const $t;
let dst = frame.plane_mut::<$t>(0);
unsafe { ptr::copy_nonoverlapping(src, dst.as_mut_ptr(), nb * in_channels) };
}
}};
}
match from.format {
SampleFormat::U8Planar | SampleFormat::U8 => fill!(u8),
SampleFormat::S16Planar | SampleFormat::S16 => fill!(i16),
SampleFormat::S32Planar | SampleFormat::S32 => fill!(i32),
SampleFormat::S64Planar | SampleFormat::S64 => {
// ffmpeg-next's typed plane API has no `i64` impl; copy the
// 8-byte samples through the raw plane pointers.
if planar {
for ch in 0..in_channels {
// SAFETY: same contract as above; the plane is
// `nb * 8` bytes.
let src = unsafe { *in_planar.add(ch) } as *const u8;
let dst = unsafe { *(*frame.as_mut_ptr()).extended_data.add(ch) };
unsafe { ptr::copy_nonoverlapping(src, dst, nb * 8) };
}
} else {
// SAFETY: same contract as above; the plane is
// `nb * 8` bytes.
let src = unsafe { *in_planar.add(ch) } as *const u8;
let dst = unsafe { *(*frame.as_mut_ptr()).extended_data.add(ch) };
unsafe { ptr::copy_nonoverlapping(src, dst, nb * 8) };
// `nb * channels * 8` bytes.
let src = unsafe { *in_planar } as *const u8;
let dst = unsafe { *(*frame.as_mut_ptr()).extended_data };
unsafe { ptr::copy_nonoverlapping(src, dst, nb * in_channels * 8) };
}
} else {
// SAFETY: same contract as above; the plane is
// `nb * channels * 8` bytes.
let src = unsafe { *in_planar } as *const u8;
let dst = unsafe { *(*frame.as_mut_ptr()).extended_data };
unsafe { ptr::copy_nonoverlapping(src, dst, nb * in_channels * 8) };
}
SampleFormat::F32Planar | SampleFormat::F32 => fill!(f32),
SampleFormat::F64Planar | SampleFormat::F64 => fill!(f64),
SampleFormat::Invalid => return Err(Box::new(Error::State)),
}
SampleFormat::F32Planar | SampleFormat::F32 => fill!(f32),
SampleFormat::F64Planar | SampleFormat::F64 => fill!(f64),
SampleFormat::Invalid => return Err(Error::State),
}
if let Err(e) = graph.get("in").unwrap().source().add(&frame) {
return Err(Error::Failed(format!(
"failed to add frame to buffersrc: {e}"
)));
}
}
// C++: `out_planar ? &buf : nullptr` — with no destination, the input is
// pushed but nothing is pulled.
if out_planar.is_null() {
return Ok(0);
}
let mut total: i64 = 0;
loop {
let pulled = graph.get("out").unwrap().sink().frame(out_frame);
match pulled {
Ok(()) => {}
Err(e) if is_drain(&e) => break,
Err(e) => {
return Err(Error::Failed(format!(
"failed to pull from buffersink: {e}"
)))
if let Err(e) = graph.get("in").unwrap().source().add(&frame) {
return Err(Box::new(Error::Failed(format!(
"failed to add frame to buffersrc: {e}"
))));
}
}
let nb = out_frame.samples() as i32;
if nb > 0 && total < i64::from(out_capacity_frames) {
let to_copy = (i64::from(out_capacity_frames) - total).min(i64::from(nb)) as i32;
for ch in 0..channels {
// SAFETY: the FFI contract guarantees at least `channels`
// entries in `out_planar` (NULL entries are skipped).
let dst = unsafe { *out_planar.add(ch as usize) };
if dst.is_null() {
continue;
}
// Output is planar f32 (enforced by open()); each plane is
// `to_copy` float samples.
let src = out_frame.plane::<f32>(ch as usize);
unsafe {
ptr::copy_nonoverlapping(src.as_ptr(), dst, to_copy as usize);
// C++: `out_planar ? &buf : nullptr` — with no destination, the input is
// pushed but nothing is pulled.
if out_planar.is_null() {
return Ok(0);
}
let mut total: i64 = 0;
loop {
let pulled = graph.get("out").unwrap().sink().frame(out_frame);
match pulled {
Ok(()) => {}
Err(e) if is_drain(&e) => break,
Err(e) => {
return Err(Box::from(Error::Failed(format!(
"failed to pull from buffersink: {e}"
))))
}
}
let nb = out_frame.samples() as i32;
if nb > 0 && total < i64::from(out_capacity_frames) {
let to_copy = (i64::from(out_capacity_frames) - total).min(i64::from(nb)) as i32;
for ch in 0..channels {
// SAFETY: the FFI contract guarantees at least `channels`
// entries in `out_planar` (NULL entries are skipped).
let dst = unsafe { *out_planar.add(ch as usize) };
if dst.is_null() {
continue;
}
// Output is planar f32 (enforced by open()); each plane is
// `to_copy` float samples.
let src = out_frame.plane::<f32>(ch as usize);
unsafe {
ptr::copy_nonoverlapping(src.as_ptr(), dst, to_copy as usize);
}
}
}
total += i64::from(nb);
}
total += i64::from(nb);
Ok(total.min(i64::from(out_capacity_frames)) as i32)
}
Ok(total.min(i64::from(out_capacity_frames)) as i32)
}
/// Signal end-of-input to the graph (flushes internal delay).
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:137` (state check)
/// and `src/audio/src/audioprocessor.cpp:210` (flush has no failure path; a
/// negative push return is logged only).
pub fn flush(&self) -> Result<()> {
let mut inner = self.inner.lock().unwrap();
/// Signal end-of-input to the graph (flushes internal delay).
///
/// `// CPP-PARITY: src/audio/c_api/processor.cpp:137` (empty handle, state)
/// and `src/audio/src/audioprocessor.cpp:210` (flush has no failure path; a
/// negative push return is logged only).
pub fn flush(self_: &CHandle) -> Result<()> {
let p = get_processor(self_)?;
let mut inner = p.inner.lock().unwrap();
let Some(graph) = inner.graph.as_mut() else {
return Err(Error::State);
};
let _ = graph.get("in").unwrap().source().flush();
Ok(())
let Some(graph) = inner.graph.as_mut() else {
return Err(Box::from(Error::State));
};
let _ = graph.get("in").unwrap().source().flush();
Ok(())
}
}
/// Format of the conversion output (always planar f32).
+66 -70
View File
@@ -23,13 +23,12 @@
use std::collections::BTreeMap;
use std::ffi::CStr;
use oakcodec::decoder::{CodecStream, Decoder as _, RetrieveAudioStatus};
use oakcodec::decoder::{receive_list_of_all_decoders, CodecStream, Decoder as _, RetrieveAudioStatus};
use oakcodec::ffmpeg::FFmpegDecoder;
use oakcodec::footagedescription::FootageDescription;
use oakcore_rs::{Rational, TimeRange};
use crate::bridge::codec::AudioStreamInfo;
use crate::error::{Error, Result};
use crate::handle::{free_handle, make_owned, CHandle};
/// Maximum channel count accepted by [`extract`]. The C++ plane array is a
/// fixed `OAKAUDIO_EXTRACT_MAX_CHANNELS` (64) stack buffer; the Rust
@@ -625,32 +624,6 @@ impl RecipOrZero for f64 {
}
}
// ---- Handle plumbing (mirrors processor.rs) --------------------------------
/// Create an empty waveform behind a refcounted handle (count 1).
pub fn init() -> Result<CHandle> {
Ok(make_owned(AudioVisualWaveform::new()))
}
/// Release one reference to a waveform (NULL/empty no-op).
pub fn free(self_: *mut CHandle) {
unsafe { free_handle(self_) };
}
/// Borrow the waveform behind a handle; `OAKAUDIO_E_INVALID` for empty.
pub fn get(self_: &CHandle) -> Result<&AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
/// Mutable variant of [`get`].
pub fn get_mut(self_: &CHandle) -> Result<&mut AudioVisualWaveform> {
// SAFETY: every non-empty handle returned by `init` boxes an
// `AudioVisualWaveform`.
unsafe { crate::handle::get_mut::<AudioVisualWaveform>(self_) }.ok_or(Error::Invalid)
}
// ---- Whole-file extraction --------------------------------------------------
/// Result of [`extract`]: channel-interleaved min/max pairs.
@@ -714,10 +687,34 @@ fn emit_points(
}
}
/// Probe a file with every registered decoder and return the first valid
/// description (non-empty decoder id and at least one stream).
///
/// Replaces the former `oakcodec_decoder_probe` C ABI call (mirrors
/// `probe_with_any_decoder` in oakcodec's ffi layer).
fn probe_description(filename: &str) -> Option<FootageDescription> {
for decoder in receive_list_of_all_decoders() {
// `continue`, not `?`: an unimplemented/unsupported decoder must
// fall through to the next one (the FFmpeg entry is the
// format-agnostic fallback last in the registry).
let Some(desc) = decoder.probe(filename, None) else {
continue;
};
if !desc.decoder().is_empty()
&& (desc.video_stream_count() > 0
|| desc.audio_stream_count() > 0
|| desc.subtitle_stream_count() > 0)
{
return Some(desc);
}
}
None
}
/// Decode a whole audio stream to a channel-interleaved min/max summary.
///
/// The stream is probed through the oakcodec decoder C ABI and decoded with
/// oakcodec's in-process FFmpeg decoder (interleaved f32 at the native
/// The stream is probed through oakcodec's in-process decoder registry and
/// decoded with oakcodec's FFmpeg decoder (interleaved f32 at the native
/// rate/layout), then reduced to one point per `samples_per_point` source
/// samples.
///
@@ -728,34 +725,33 @@ pub fn extract(
stream_index: i32,
samples_per_point: i32,
) -> Result<ExtractOutcome> {
// Probe for the stream's native rate/layout (stateless).
// SAFETY: `filename` is a NUL-terminated C string (validated by the FFI
// layer); the probe handle is freed on every path below.
let mut probe = unsafe { crate::bridge::codec::oakcodec_decoder_probe(filename.as_ptr()) };
if probe.is_null() {
return Err(Error::NotFound);
// Probe for the stream's native rate/layout with the in-process decoder
// registry (the Rust equivalent of the former oakcodec decoder probe
// C ABI call).
let filename_str = filename.to_string_lossy();
let desc = probe_description(&filename_str).ok_or(Box::new(Error::NotFound))?;
let ap = desc
.get_audio_stream(stream_index as usize)
.ok_or(Box::new(Error::NotFound))?;
// The probed stream metadata is a plain value type (oakcodec's
// `AudioParams`); the fields map one-to-one onto the former
// `oakcodec_audio_stream_info` POD.
let sample_rate = ap.sample_rate;
let channel_count = ap.channel_count();
let channel_layout = ap.channel_layout;
let container_stream_index = ap.stream_index;
let duration_ts = ap.duration;
let (time_base_num, time_base_den) = ap.time_base;
if sample_rate <= 0 || channel_count <= 0 {
return Err(Box::new(Error::Failed("invalid audio stream".to_string())));
}
let mut info = unsafe { std::mem::zeroed::<AudioStreamInfo>() };
let r = unsafe {
crate::bridge::codec::oakcodec_decoder_probe_get_audio_stream(
probe,
stream_index,
&mut info,
)
};
// SAFETY: `probe` was created above and is no longer used.
unsafe { crate::bridge::codec::oakcodec_decoder_free(&mut probe) };
if r != 0 {
return Err(Error::NotFound);
}
if info.sample_rate <= 0 || info.channel_count <= 0 {
return Err(Error::Failed("invalid audio stream".to_string()));
}
let channels = info.channel_count;
let channels = channel_count;
if channels > EXTRACT_MAX_CHANNELS {
return Err(Error::Failed(format!(
return Err(Box::new(Error::Failed(format!(
"stream has {channels} channels (max {EXTRACT_MAX_CHANNELS})"
)));
))));
}
// Decode the whole stream through oakcodec's FFmpeg decoder
@@ -764,23 +760,23 @@ pub fn extract(
// fb_audio_graph to obtain planar f32).
let decoder = FFmpegDecoder::new();
let stream = CodecStream::with_block(
filename.to_string_lossy().into_owned(),
info.stream_index,
filename_str.into_owned(),
container_stream_index,
None,
);
if let Err(e) = decoder.open(&stream) {
return Err(Error::Failed(format!("failed to open decoder: {e:?}")));
return Err(Box::new(Error::Failed(format!("failed to open decoder: {e:?}"))));
}
if info.time_base_num <= 0 || info.time_base_den <= 0 || info.duration_ts <= 0 {
if time_base_num <= 0 || time_base_den <= 0 || duration_ts <= 0 {
let _ = decoder.close();
return Err(Error::Failed("invalid audio stream duration".to_string()));
return Err(Box::new(Error::Failed("invalid audio stream duration".to_string())));
}
let duration_sec =
info.duration_ts as f64 * f64::from(info.time_base_num) / f64::from(info.time_base_den);
let total_frames = (duration_sec * f64::from(info.sample_rate)).round() as i64;
let layout_mask = if info.channel_layout != 0 {
info.channel_layout
duration_ts as f64 * f64::from(time_base_num) / f64::from(time_base_den);
let total_frames = (duration_sec * f64::from(sample_rate)).round() as i64;
let layout_mask = if channel_layout != 0 {
channel_layout
} else {
ffmpeg_next::ChannelLayout::default(channels).bits()
};
@@ -796,11 +792,11 @@ pub fn extract(
while offset < total_frames {
let frames = (total_frames - offset).min(CHUNK_FRAMES);
let range = TimeRange::new(
Rational::new(offset, i64::from(info.sample_rate)),
Rational::new(offset + frames, i64::from(info.sample_rate)),
Rational::new(offset, i64::from(sample_rate)),
Rational::new(offset + frames, i64::from(sample_rate)),
);
let mut buf = vec![0f32; frames as usize * channels as usize];
match decoder.retrieve_audio(&mut buf, &range, info.sample_rate, layout_mask) {
match decoder.retrieve_audio(&mut buf, &range, sample_rate, layout_mask) {
Ok(RetrieveAudioStatus::Success) => {
append_pending(&mut pending, &buf, channels);
emit_points(
@@ -812,11 +808,11 @@ pub fn extract(
);
}
Ok(status) => {
result = Err(Error::Failed(format!("audio retrieve failed: {status:?}")));
result = Err(Box::new(Error::Failed(format!("audio retrieve failed: {status:?}"))));
break;
}
Err(e) => {
result = Err(Error::Failed(format!("audio retrieve failed: {e:?}")));
result = Err(Box::new(Error::Failed(format!("audio retrieve failed: {e:?}"))));
break;
}
}