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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¶ms) {
|
||||
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(¶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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user