Files
oak-editor/crates/oak-audio/src/outputdevice.rs
T
Mike-Solar 244d5e860f
CI / Build & test (Windows) (push) Failing after 7s
workspace: kebab-case crates, app under crates/oak-app, shared versions
All crates take the oak-* kebab-case naming (oak-audio, oak-codec,
oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin,
oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the
lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: ->
oak_core::, ...) across all 226 referencing files.

The GUI application moves from the workspace root into
crates/oak-app/: src/, build.rs (paths fixed for the new location) and
tests/ travel with it, the root Cargo.toml becomes workspace-only
([workspace] + workspace.package + profiles), and the app package
inherits the workspace version. The screenshots example becomes a
standalone crate examples/simple_player/ with its own Cargo.toml.

Every crate now inherits the single workspace version
(version.workspace = true), and the workflows' crate paths and the
build docs follow the renames.

Validated with a clean cargo check --workspace.
2026-08-22 16:58:37 +08:00

211 lines
7.0 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 cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{
BufferSize, Device, Host, SampleFormat, SampleRate, Stream, StreamConfig,
SupportedBufferSize,
};
use crate::previewdevice::PreviewAudioDevice;
/// 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) 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().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 = 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 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);
};
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(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())
}