Files
oak-editor/crates/oak-audio/src/outputdevice.rs
T
Mike-Solar ee7ea18d94 clippy: clear the workspace errors and apply the machine fixes
- Mark the raw-pointer interop entry points unsafe with # Safety docs
  (oak-core upload/download/frame-from-pixels, oak-audio convert) and
  satisfy the existing callers (tests).
- mut_from_ref: allow with the ABI contract documented (the handle
  get_mut helpers in oak-timeline/oak-render/oak-task take the shared
  reference the C ABI passes; exclusivity is the caller's unsafe
  contract).
- Fix the eq_op in the white-balance normalization (green / green).
- Apply cargo clippy --fix across the workspace (redundant closures and
  field names, field reassignment, items after test modules, ...).
- Revert the replace_box fix in image_effect's clip_define: a
  redefinition must allocate a new box, otherwise the old clip handle
  stays valid and the HS-map replace contract (clip != clip2) breaks.
- 283 warnings remain; they are all non-machine-applicable
  (chunks_exact -> as_chunks needs a manual iter_mut, too_many_arguments,
  complex types, missing Safety docs, ...) and are tracked as the
  follow-up.
2026-09-15 19:29:32 +08:00

245 lines
8.3 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! 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
//! (empty buffer) writes silence. The stream is opened lazily on the
//! first pushed samples and re-opened when the format or device changes.
//!
//! The callback never allocates or locks anything but the preview
//! device's mutex (a short critical section; the buffer is drained in
//! whole frames).
use std::cell::RefCell;
use std::sync::Arc;
use crate::previewdevice::PreviewAudioDevice;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{BufferSize, Device, Host, SampleFormat, Stream, StreamConfig, SupportedBufferSize};
/// The default frames-per-buffer requested for the preview stream
/// (clamped to the device's supported range).
const FRAMES_PER_BUFFER: u32 = 512;
/// OAK_DEBUG_AUDIO-gated diagnostics for the output stream.
fn stream_dbg_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var_os("OAK_DEBUG_AUDIO").is_some())
}
/// An open (or openable) cpal output stream.
pub struct PortAudioOutput {
/// Audio host/session (created lazily; `None` when unavailable).
host: Option<Host>,
/// The live stream (None when closed).
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 (the host is created lazily on first use).
pub fn new() -> PortAudioOutput {
PortAudioOutput {
host: Some(cpal::default_host()),
stream: None,
opened: None,
}
}
/// Whether an output stream is currently running.
pub fn is_running(&self) -> bool {
self.stream.is_some()
}
/// Close the stream (pause + drop).
pub fn close(&mut self) {
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 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,
device: i32,
rate: i32,
channels: i32,
sink: Arc<PreviewAudioDevice>,
) -> Result<(), String> {
if rate <= 0 || channels <= 0 {
return Err("invalid output format".into());
}
if self.opened == Some((device, rate, channels)) && self.stream.is_some() {
return Ok(());
}
self.close();
if self.host.is_none() {
self.host = Some(cpal::default_host())
}
let host = self.host.as_ref().unwrap();
let output_device =
resolve_device(host, device).ok_or_else(|| "no output device available".to_string())?;
let config = pick_config(&output_device, rate, channels)?;
if stream_dbg_enabled() {
eprintln!(
"[audio-stream] config: {} Hz, {} ch, buffer {:?}",
config.sample_rate, config.channels, config.buffer_size
);
}
// 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 = RefCell::new(Vec::<u8>::new());
// OAK_DEBUG_AUDIO: true device-side request rate (callbacks/s and
// frames/s), aggregated once per second — this is the ground truth
// for production/consumption mismatch hunts.
let dbg_stats = RefCell::new((0u64, 0u64, std::time::Instant::now()));
let callback = move |out: &mut [f32], _info: &cpal::OutputCallbackInfo| {
let total = out.len();
if stream_dbg_enabled() {
let mut st = dbg_stats.borrow_mut();
st.0 += total as u64;
st.1 += 1;
let el = st.2.elapsed();
if el >= std::time::Duration::from_secs(1) {
let secs = el.as_secs_f64();
eprintln!(
"[audio-stream] callback: {:.1} calls/s, {:.0} samples/s ({} ch)",
st.1 as f64 / secs,
st.0 as f64 / secs,
channels_usize
);
st.0 = 0;
st.1 = 0;
st.2 = std::time::Instant::now();
}
}
let mut scratch = scratch.borrow_mut();
scratch.resize(total * 4, 0);
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;
}
}
sink_cb.add_output_frames((total / channels_usize) as i64);
// Account the zero-filled tail separately so the engine can
// resync after an underrun instead of drifting out of sync.
sink_cb.add_underrun_frames((total / channels_usize - frames_got) as i64);
};
let err_callback = |err: cpal::Error| {
eprintln!("output stream error: {err}");
};
let stream = output_device
.build_output_stream(config, callback, err_callback, None)
.map_err(|e| format!("output stream open failed: {e}"))?;
stream
.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(configs) = device.supported_output_configs() {
for c in configs {
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())
}