CI / Build & test (Windows) (push) Failing after 7s
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.
211 lines
7.0 KiB
Rust
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())
|
|
}
|