fix: support integer CPAL devices and recover voice-over/audio failures
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
parent
a4536bcfed
commit
7151d329d6
|
|
@ -2,7 +2,7 @@
|
|||
//! ("Device Class"), the output device, the I/O buffer size and the sample rate.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use filmcraft_engine::settings::AudioHardwarePrefs;
|
||||
|
|
@ -11,6 +11,7 @@ use filmcraft_ui_egui::{AudioDevices, AudioOut};
|
|||
pub struct CpalOut {
|
||||
stream: Option<cpal::Stream>,
|
||||
played: Arc<AtomicU64>,
|
||||
failed: Arc<AtomicBool>,
|
||||
rate: u32,
|
||||
channels: u16,
|
||||
hw: AudioHardwarePrefs,
|
||||
|
|
@ -41,34 +42,82 @@ fn output_device(h: &cpal::Host, name: &str) -> Option<cpal::Device> {
|
|||
}
|
||||
|
||||
impl CpalOut {
|
||||
pub fn new() -> Option<Self> {
|
||||
pub fn new() -> Self {
|
||||
let host = cpal::default_host();
|
||||
let dev = host.default_output_device()?;
|
||||
let cfg = dev.default_output_config().ok()?;
|
||||
Some(Self {
|
||||
let cfg = host.default_output_device().and_then(|dev| dev.default_output_config().ok());
|
||||
Self {
|
||||
stream: None,
|
||||
played: Arc::new(AtomicU64::new(0)),
|
||||
rate: cfg.sample_rate().0,
|
||||
channels: cfg.channels(),
|
||||
failed: Arc::new(AtomicBool::new(false)),
|
||||
rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0),
|
||||
channels: cfg.as_ref().map_or(2, |c| c.channels()),
|
||||
hw: AudioHardwarePrefs::default(),
|
||||
document_rate: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The stream configuration the settings ask for, falling back to the device default.
|
||||
fn config(&self, dev: &cpal::Device) -> Result<cpal::SupportedStreamConfig, String> {
|
||||
let default = dev.default_output_config().map_err(|e| e.to_string())?;
|
||||
let want = if self.hw.force_document_rate { self.document_rate.unwrap_or(self.hw.sample_rate) } else { self.hw.sample_rate };
|
||||
let exact = dev.supported_output_configs().ok().and_then(|mut it| {
|
||||
it.find(|c| {
|
||||
c.sample_format() == cpal::SampleFormat::F32
|
||||
&& c.channels() == default.channels()
|
||||
&& (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&want)
|
||||
})
|
||||
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(want)))
|
||||
let exact = dev.supported_output_configs().ok().and_then(|it| {
|
||||
it.filter(|c| c.channels() > 0 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&want))
|
||||
.max_by_key(|c| {
|
||||
(c.channels() == default.channels(), c.sample_format() == cpal::SampleFormat::F32, c.sample_format() == default.sample_format())
|
||||
})
|
||||
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(want)))
|
||||
});
|
||||
Ok(exact.unwrap_or(default))
|
||||
}
|
||||
|
||||
fn converted_stream<T>(
|
||||
&self,
|
||||
dev: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>,
|
||||
) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
T: cpal::SizedSample + cpal::FromSample<f32>,
|
||||
{
|
||||
let channels = usize::from(config.channels);
|
||||
let played = self.played.clone();
|
||||
let failed = self.failed.clone();
|
||||
let error_flag = failed.clone();
|
||||
let mut scratch = Vec::new();
|
||||
dev.build_output_stream(
|
||||
config,
|
||||
move |buf: &mut [T], _| {
|
||||
// Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread.
|
||||
if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() {
|
||||
buf.fill(T::EQUILIBRIUM);
|
||||
if !failed.swap(true, Ordering::SeqCst) {
|
||||
eprintln!("filmcraft: unable to allocate the audio conversion buffer");
|
||||
}
|
||||
return;
|
||||
}
|
||||
scratch.resize(buf.len(), 0.0);
|
||||
fill(&mut scratch, channels);
|
||||
convert_samples(&scratch, buf);
|
||||
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
|
||||
},
|
||||
move |e| stream_error(&error_flag, e),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_error(failed: &AtomicBool, e: cpal::StreamError) {
|
||||
if !failed.swap(true, Ordering::SeqCst) {
|
||||
eprintln!("filmcraft: audio stream error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_samples<T: cpal::Sample + cpal::FromSample<f32>>(input: &[f32], output: &mut [T]) {
|
||||
// Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly.
|
||||
let upper = f32::from_bits(1.0_f32.to_bits() - 1);
|
||||
for (sample, converted) in input.iter().zip(output) {
|
||||
*converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioOut for CpalOut {
|
||||
|
|
@ -78,6 +127,9 @@ impl AudioOut for CpalOut {
|
|||
let dev = output_device(&host, &self.hw.default_output).ok_or("no output device")?;
|
||||
let cfg = self.config(&dev)?;
|
||||
let channels = cfg.channels() as usize;
|
||||
if channels == 0 || cfg.sample_rate().0 == 0 {
|
||||
return Err("the output device returned an invalid channel count or sample rate".into());
|
||||
}
|
||||
let mut config: cpal::StreamConfig = cfg.clone().into();
|
||||
if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size()
|
||||
&& (*min..=*max).contains(&self.hw.buffer_size)
|
||||
|
|
@ -87,8 +139,9 @@ impl AudioOut for CpalOut {
|
|||
self.rate = cfg.sample_rate().0;
|
||||
self.channels = cfg.channels();
|
||||
self.played.store(0, Ordering::SeqCst);
|
||||
self.failed.store(false, Ordering::SeqCst);
|
||||
let played = self.played.clone();
|
||||
let err = |e| eprintln!("filmcraft: audio stream error: {e}");
|
||||
let failed = self.failed.clone();
|
||||
let stream = match cfg.sample_format() {
|
||||
cpal::SampleFormat::F32 => dev.build_output_stream(
|
||||
&config,
|
||||
|
|
@ -96,9 +149,19 @@ impl AudioOut for CpalOut {
|
|||
fill(buf, channels);
|
||||
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
|
||||
},
|
||||
err,
|
||||
move |e| stream_error(&failed, e),
|
||||
None,
|
||||
),
|
||||
cpal::SampleFormat::I8 => self.converted_stream::<i8>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I16 => self.converted_stream::<i16>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I24 => self.converted_stream::<cpal::I24>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I32 => self.converted_stream::<i32>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I64 => self.converted_stream::<i64>(&dev, &config, fill),
|
||||
cpal::SampleFormat::U8 => self.converted_stream::<u8>(&dev, &config, fill),
|
||||
cpal::SampleFormat::U16 => self.converted_stream::<u16>(&dev, &config, fill),
|
||||
cpal::SampleFormat::U32 => self.converted_stream::<u32>(&dev, &config, fill),
|
||||
cpal::SampleFormat::U64 => self.converted_stream::<u64>(&dev, &config, fill),
|
||||
cpal::SampleFormat::F64 => self.converted_stream::<f64>(&dev, &config, fill),
|
||||
other => return Err(format!("unsupported sample format {other:?}")),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
|
@ -116,7 +179,7 @@ impl AudioOut for CpalOut {
|
|||
self.channels as usize
|
||||
}
|
||||
fn played_frames(&self) -> Option<u64> {
|
||||
self.stream.as_ref().map(|_| self.played.load(Ordering::SeqCst))
|
||||
self.stream.as_ref().filter(|_| !self.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
|
||||
}
|
||||
fn devices(&self) -> AudioDevices {
|
||||
let h = host(&self.hw.device_class);
|
||||
|
|
@ -138,3 +201,35 @@ impl AudioOut for CpalOut {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cpal::Sample;
|
||||
|
||||
#[test]
|
||||
fn integer_conversion_clips_and_preserves_interleaved_channels() {
|
||||
let mut signed = [0_i16; 8];
|
||||
convert_samples(&[-2.0, 2.0, -0.5, 0.5, 0.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY], &mut signed);
|
||||
assert_eq!(signed, [i16::MIN, i16::MAX, -16_384, 16_384, 0, 0, 0, 0]);
|
||||
let mut unsigned = [0_u16; 3];
|
||||
convert_samples(&[-1.0, 0.0, 1.0], &mut unsigned);
|
||||
assert_eq!(unsigned, [0, 32_768, u16::MAX]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_scale_24_bit_samples_remain_representable() {
|
||||
let mut samples = [cpal::I24::EQUILIBRIUM; 3];
|
||||
convert_samples(&[-1.0, 0.0, 1.0], &mut samples);
|
||||
assert_eq!(samples.map(|s| s.inner()), [-8_388_608, 0, 8_388_607]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_stream_relinquishes_the_playback_clock() {
|
||||
let failed = AtomicBool::new(false);
|
||||
stream_error(&failed, cpal::StreamError::DeviceNotAvailable);
|
||||
assert!(failed.load(Ordering::SeqCst));
|
||||
let output = CpalOut::new();
|
||||
assert_eq!(output.played_frames(), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ pub struct CpalIn {
|
|||
host: String,
|
||||
buf: Arc<Mutex<Vec<Vec<f32>>>>,
|
||||
stop: Option<mpsc::Sender<()>>,
|
||||
worker: Option<std::thread::JoinHandle<()>>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl CpalIn {
|
||||
pub fn new(host: &str) -> Self {
|
||||
Self { host: host.to_string(), buf: Arc::default(), stop: None }
|
||||
Self { host: host.to_string(), buf: Arc::default(), stop: None, worker: None, error: Arc::default() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +48,63 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn report_error(error: &Mutex<Option<String>>, message: String) {
|
||||
let mut error = lock(error);
|
||||
if error.is_none() {
|
||||
eprintln!("filmcraft: audio input error: {message}");
|
||||
*error = Some(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn append<T: cpal::Sample>(data: &[T], channels: usize, buf: &Mutex<Vec<Vec<f32>>>, error: &Mutex<Option<String>>)
|
||||
where
|
||||
f32: cpal::FromSample<T>,
|
||||
{
|
||||
if lock(error).is_some() {
|
||||
return;
|
||||
}
|
||||
let mut b = lock(buf);
|
||||
if channels == 0 || channels > 256 || data.len() > 1_048_576 {
|
||||
report_error(error, "invalid input device buffer".into());
|
||||
return;
|
||||
}
|
||||
if b.len() != channels {
|
||||
*b = vec![Vec::new(); channels];
|
||||
}
|
||||
// The recorder drains at stop. Bound accumulated samples and report exhaustion instead of
|
||||
// allowing a long take or a broken/unpaced driver to exhaust the application heap.
|
||||
let samples = b.first().map_or(0, Vec::len).saturating_mul(channels).saturating_add(data.len());
|
||||
let frames = data.len() / channels;
|
||||
if samples > 268_435_456 || b.iter_mut().any(|c| c.try_reserve(frames).is_err()) {
|
||||
report_error(error, "voice-over input buffer is full; recording failed".into());
|
||||
return;
|
||||
}
|
||||
for frame in data.chunks_exact(channels) {
|
||||
for (channel, sample) in b.iter_mut().zip(frame) {
|
||||
let sample = sample.to_sample::<f32>();
|
||||
channel.push(if sample.is_finite() { sample.clamp(-1.0, 1.0) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn input_stream<T: cpal::SizedSample>(
|
||||
dev: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
buf: Arc<Mutex<Vec<Vec<f32>>>>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
f32: cpal::FromSample<T>,
|
||||
{
|
||||
let channels = usize::from(config.channels);
|
||||
let stream_error = error.clone();
|
||||
dev.build_input_stream(config, move |data: &[T], _| append(data, channels, &buf, &error), move |e| report_error(&stream_error, e.to_string()), None)
|
||||
}
|
||||
|
||||
impl AudioInput for CpalIn {
|
||||
fn configure_host(&mut self, name: &str) {
|
||||
self.host = name.to_string();
|
||||
}
|
||||
fn devices(&self) -> Vec<String> {
|
||||
host(&self.host).input_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
|
@ -56,68 +114,77 @@ impl AudioInput for CpalIn {
|
|||
fn start(&mut self, name: &str, sample_rate: u32) -> Result<InputFormat, String> {
|
||||
self.stop();
|
||||
lock(&self.buf).clear();
|
||||
*lock(&self.error) = None;
|
||||
let (host_name, name) = (self.host.clone(), name.to_string());
|
||||
let buf = self.buf.clone();
|
||||
let error = self.error.clone();
|
||||
let (fmt_tx, fmt_rx) = mpsc::channel::<Result<InputFormat, String>>();
|
||||
let (stop_tx, stop_rx) = mpsc::channel::<()>();
|
||||
std::thread::Builder::new()
|
||||
.name("voice-over input".into())
|
||||
.spawn(move || {
|
||||
let open = || -> Result<(cpal::Stream, InputFormat), String> {
|
||||
let h = host(&host_name);
|
||||
let dev = device(&h, &name).ok_or("no input device")?;
|
||||
let default = dev.default_input_config().map_err(|e| e.to_string())?;
|
||||
// the sequence rate when the device offers it in f32, else the device default
|
||||
let cfg = dev
|
||||
.supported_input_configs()
|
||||
.ok()
|
||||
.and_then(|mut it| {
|
||||
it.find(|c| c.sample_format() == cpal::SampleFormat::F32 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&sample_rate))
|
||||
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(sample_rate)))
|
||||
})
|
||||
.unwrap_or(default);
|
||||
if cfg.sample_format() != cpal::SampleFormat::F32 {
|
||||
return Err(format!("unsupported input sample format {:?}", cfg.sample_format()));
|
||||
}
|
||||
let ch = cfg.channels() as usize;
|
||||
let fmt = InputFormat { sample_rate: cfg.sample_rate().0, channels: cfg.channels() };
|
||||
let stream = dev
|
||||
.build_input_stream(
|
||||
&cfg.into(),
|
||||
move |data: &[f32], _| {
|
||||
let mut b = lock(&buf);
|
||||
if b.len() != ch {
|
||||
*b = vec![Vec::new(); ch];
|
||||
}
|
||||
for frame in data.chunks_exact(ch.max(1)) {
|
||||
for (c, x) in frame.iter().enumerate() {
|
||||
b[c].push(*x);
|
||||
}
|
||||
}
|
||||
},
|
||||
|e| eprintln!("filmcraft: audio input error: {e}"),
|
||||
None,
|
||||
)
|
||||
self.worker = Some(
|
||||
std::thread::Builder::new()
|
||||
.name("voice-over input".into())
|
||||
.spawn(move || {
|
||||
let open = || -> Result<(cpal::Stream, InputFormat), String> {
|
||||
let h = host(&host_name);
|
||||
let dev = device(&h, &name).ok_or("no input device")?;
|
||||
let default = dev.default_input_config().map_err(|e| e.to_string())?;
|
||||
// Prefer f32 at the sequence rate, retaining integer-only input devices.
|
||||
let cfg = dev
|
||||
.supported_input_configs()
|
||||
.ok()
|
||||
.and_then(|it| {
|
||||
it.filter(|c| c.channels() > 0 && (c.min_sample_rate().0..=c.max_sample_rate().0).contains(&sample_rate))
|
||||
.max_by_key(|c| (c.channels() == default.channels(), c.sample_format() == cpal::SampleFormat::F32))
|
||||
.and_then(|c| c.try_with_sample_rate(cpal::SampleRate(sample_rate)))
|
||||
})
|
||||
.unwrap_or(default);
|
||||
if cfg.channels() == 0 || cfg.channels() > 256 || cfg.sample_rate().0 == 0 {
|
||||
return Err("the input device returned an invalid format".into());
|
||||
}
|
||||
let fmt = InputFormat { sample_rate: cfg.sample_rate().0, channels: cfg.channels() };
|
||||
let format = cfg.sample_format();
|
||||
let config = cfg.into();
|
||||
let stream = match format {
|
||||
cpal::SampleFormat::I8 => input_stream::<i8>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::I16 => input_stream::<i16>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::I24 => input_stream::<cpal::I24>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::I32 => input_stream::<i32>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::I64 => input_stream::<i64>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::U8 => input_stream::<u8>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::U16 => input_stream::<u16>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::U32 => input_stream::<u32>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::U64 => input_stream::<u64>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::F32 => input_stream::<f32>(&dev, &config, buf.clone(), error.clone()),
|
||||
cpal::SampleFormat::F64 => input_stream::<f64>(&dev, &config, buf.clone(), error.clone()),
|
||||
other => return Err(format!("unsupported input sample format {other:?}")),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
Ok((stream, fmt))
|
||||
};
|
||||
match open() {
|
||||
Ok((stream, fmt)) => {
|
||||
let _ = fmt_tx.send(Ok(fmt));
|
||||
// keep the stream alive until stop (or the sender is dropped)
|
||||
let _ = stop_rx.recv();
|
||||
drop(stream);
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
Ok((stream, fmt))
|
||||
};
|
||||
let opened = std::panic::catch_unwind(std::panic::AssertUnwindSafe(open)).unwrap_or_else(|_| Err("audio input worker panicked".into()));
|
||||
match opened {
|
||||
Ok((stream, fmt)) => {
|
||||
let _ = fmt_tx.send(Ok(fmt));
|
||||
// keep the stream alive until stop (or the sender is dropped)
|
||||
let _ = stop_rx.recv();
|
||||
drop(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fmt_tx.send(Err(e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fmt_tx.send(Err(e));
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let fmt = fmt_rx.recv().map_err(|e| e.to_string())??;
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
);
|
||||
self.stop = Some(stop_tx);
|
||||
Ok(fmt)
|
||||
match fmt_rx.recv().map_err(|e| e.to_string()).and_then(|f| f) {
|
||||
Ok(fmt) => Ok(fmt),
|
||||
Err(e) => {
|
||||
self.stop();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn read(&mut self, frames: usize) -> Vec<Vec<f32>> {
|
||||
let mut b = lock(&self.buf);
|
||||
|
|
@ -131,9 +198,56 @@ impl AudioInput for CpalIn {
|
|||
fn discard(&mut self) {
|
||||
lock(&self.buf).iter_mut().for_each(Vec::clear);
|
||||
}
|
||||
fn error(&self) -> Option<String> {
|
||||
lock(&self.error).clone()
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
if let Some(tx) = self.stop.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
if let Some(worker) = self.worker.take()
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
report_error(&self.error, "audio input worker panicked".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CpalIn {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signed_and_unsigned_input_is_deinterleaved_to_float() {
|
||||
let buf = Mutex::new(Vec::new());
|
||||
let error = Mutex::new(None);
|
||||
append(&[i16::MIN, i16::MAX, 0, 16384], 2, &buf, &error);
|
||||
let data = lock(&buf);
|
||||
assert_eq!(data[0], [-1.0, 0.0]);
|
||||
assert!((data[1][0] - 1.0).abs() < 0.0001);
|
||||
assert_eq!(data[1][1], 0.5);
|
||||
drop(data);
|
||||
lock(&buf).clear();
|
||||
append(&[0_u16, 32768, 65535, 32768], 2, &buf, &error);
|
||||
let data = lock(&buf);
|
||||
assert_eq!(data[1], [0.0, 0.0]);
|
||||
assert_eq!(data[0][0], -1.0);
|
||||
assert!(lock(&error).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_is_reported_without_panicking_or_appending() {
|
||||
let buf = Mutex::new(Vec::new());
|
||||
let error = Mutex::new(None);
|
||||
append(&[0.0_f32], 0, &buf, &error);
|
||||
assert!(lock(&error).is_some());
|
||||
append(&[1.0_f32], 1, &buf, &error);
|
||||
assert!(lock(&buf).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,10 +164,9 @@ fn main() -> eframe::Result {
|
|||
{
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
if let Some(out) = audio::CpalOut::new() {
|
||||
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`)
|
||||
app.audio = Some(Box::new(out));
|
||||
}
|
||||
// Keep device selection available even when the default device is unavailable.
|
||||
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
|
||||
app.audio = Some(Box::new(audio::CpalOut::new()));
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ pub struct InputFormat {
|
|||
|
||||
/// An audio input device (cpal on desktop, [`SyntheticInput`] headless).
|
||||
pub trait AudioInput: Send {
|
||||
/// Select the host for device discovery and the next take, preserving any active capture.
|
||||
fn configure_host(&mut self, _host: &str) {}
|
||||
/// Names of the devices that can be chosen as Source.
|
||||
fn devices(&self) -> Vec<String>;
|
||||
/// Input channels of `device` ("" = the default device).
|
||||
|
|
@ -52,6 +54,10 @@ pub trait AudioInput: Send {
|
|||
/// Up to `frames` captured frames (planar, every device channel), oldest first. A live device
|
||||
/// returns what it has captured; a synthetic one generates exactly `frames`.
|
||||
fn read(&mut self, frames: usize) -> Vec<Vec<f32>>;
|
||||
/// A device error must not be saved as a successful silent take.
|
||||
fn error(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// Drop everything captured so far: the next sample read is "now".
|
||||
fn discard(&mut self);
|
||||
fn stop(&mut self);
|
||||
|
|
@ -172,6 +178,8 @@ pub struct VoiceOver {
|
|||
pub rec: Option<Recording>,
|
||||
/// Frames already read from the input in this recording (captured before stop).
|
||||
captured: Vec<f32>,
|
||||
/// A stopped take awaiting a successful save; retries keep its original end point.
|
||||
pending_stop: Option<Tick>,
|
||||
}
|
||||
|
||||
impl VoiceOver {
|
||||
|
|
@ -333,13 +341,17 @@ fn start(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let vo = s.prefs.voice_over.clone();
|
||||
let punch = mark_in.is_some() && mark_out.is_some_and(|o| Some(o) > mark_in);
|
||||
let record_start = if punch { mark_in.unwrap_or_default() } else { time_p(s, p, "").unwrap_or(s.playhead()) };
|
||||
let preroll = Tick::from_seconds_f64(f64_p(p, "preroll").unwrap_or(vo.preroll_seconds).max(0.0));
|
||||
if record_start < Tick::ZERO {
|
||||
return Err(bad("audio.voiceover.start", "the record point cannot be negative"));
|
||||
}
|
||||
let preroll = Tick::from_seconds_f64(f64_p(p, "preroll").unwrap_or(vo.preroll_seconds).clamp(0.0, 60.0));
|
||||
let capture_start = (record_start - preroll).max(Tick::ZERO);
|
||||
let punch_out = if punch { mark_out } else { None };
|
||||
let device = if vo.source.is_empty() { s.prefs.audio_hardware.default_input.clone() } else { vo.source.clone() };
|
||||
let format = s.voiceover.input().start(&device, sr).map_err(|e| EngineError::Other(format!("voice-over input: {e}")))?;
|
||||
let channel = vo.input_channel.min(format.channels.saturating_sub(1) as u32);
|
||||
s.voiceover.captured.clear();
|
||||
s.voiceover.pending_stop = None;
|
||||
s.voiceover.rec = Some(Recording { seq: seq_id, track, record_start, capture_start, punch_out, format, channel });
|
||||
let cues: Vec<i64> = if vo.countdown_sound_cues { cue_times(capture_start, record_start).into_iter().map(|t| t.0).collect() } else { Vec::new() };
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
|
|
@ -357,11 +369,18 @@ fn start(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// Playback really started at `time`: drop what was captured so far and count from there.
|
||||
fn sync(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.voiceover.pending_stop.is_some() {
|
||||
return Err(bad("audio.voiceover.sync", "save or discard the stopped take before restarting capture"));
|
||||
}
|
||||
if s.voiceover.rec.is_none() {
|
||||
return Err(bad("audio.voiceover.sync", "no voice-over is recording"));
|
||||
}
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
s.voiceover.input().discard();
|
||||
s.voiceover.captured.clear();
|
||||
s.voiceover.pending_stop = None;
|
||||
let Some(rec) = s.voiceover.rec.as_mut() else { return Err(bad("audio.voiceover.sync", "no voice-over is recording")) };
|
||||
rec.capture_start = t.min(rec.record_start);
|
||||
rec.capture_start = t.clamp(Tick::ZERO, rec.record_start);
|
||||
Ok(json!({"captureStart": rec.capture_start.0}))
|
||||
}
|
||||
|
||||
|
|
@ -381,29 +400,52 @@ fn record_dir(s: &Session, p: &Value) -> String {
|
|||
}
|
||||
|
||||
fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let rec = s.voiceover.rec.take().ok_or_else(|| bad("audio.voiceover.stop", "no voice-over is recording"))?;
|
||||
let t_stop = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
let rec = s.voiceover.rec.clone().ok_or_else(|| bad("audio.voiceover.stop", "no voice-over is recording"))?;
|
||||
if bool_p(p, "discard").unwrap_or(false) {
|
||||
s.voiceover.input().stop();
|
||||
s.voiceover.rec = None;
|
||||
s.voiceover.pending_stop = None;
|
||||
s.voiceover.captured.clear();
|
||||
return Ok(json!({"recording": false, "placed": false}));
|
||||
}
|
||||
let t_stop = s.voiceover.pending_stop.unwrap_or_else(|| time_p(s, p, "").unwrap_or(s.playhead()));
|
||||
let sr = rec.format.sample_rate.max(1) as i64;
|
||||
let c0 = rec.capture_start.to_units_floor(sr);
|
||||
let r0 = rec.record_start.to_units_floor(sr);
|
||||
let end = rec.punch_out.map_or(t_stop, |o| o.min(t_stop)).to_units_floor(sr);
|
||||
// read the input up to the end of the take
|
||||
let want = (end - c0).max(0) as usize;
|
||||
let have = s.voiceover.captured.len();
|
||||
let input = s.voiceover.input();
|
||||
let got = input.read(want.saturating_sub(have));
|
||||
input.stop();
|
||||
let ch = rec.channel as usize;
|
||||
let mut mono = std::mem::take(&mut s.voiceover.captured);
|
||||
if let Some(c) = got.get(ch).or(got.first()) {
|
||||
mono.extend_from_slice(c);
|
||||
let want = end.saturating_sub(c0).max(0) as usize;
|
||||
if want.saturating_mul(usize::from(rec.format.channels).max(1)) > 268_435_456 {
|
||||
return Err(bad("audio.voiceover.stop", "recording exceeds the input buffer limit"));
|
||||
}
|
||||
mono.resize(want, 0.0);
|
||||
let skip = (r0 - c0).clamp(0, want as i64) as usize;
|
||||
let samples = &mono[skip..];
|
||||
if samples.is_empty() || bool_p(p, "discard").unwrap_or(false) {
|
||||
let have = s.voiceover.captured.len();
|
||||
s.voiceover.captured.try_reserve(want.saturating_sub(have)).map_err(|e| bad("audio.voiceover.stop", format!("unable to allocate the recording: {e}")))?;
|
||||
s.voiceover.pending_stop = Some(t_stop);
|
||||
let input = s.voiceover.input();
|
||||
input.stop();
|
||||
let got = input.read(want.saturating_sub(have));
|
||||
let input_error = input.error();
|
||||
let ch = rec.channel as usize;
|
||||
if let Some(c) = got.get(ch).or(got.first()) {
|
||||
s.voiceover.captured.extend_from_slice(c);
|
||||
}
|
||||
if let Some(error) = input_error {
|
||||
return Err(bad("audio.voiceover.stop", format!("input failed: {error}")));
|
||||
}
|
||||
s.voiceover.captured.resize(want, 0.0);
|
||||
let skip = r0.saturating_sub(c0).clamp(0, want as i64) as usize;
|
||||
let samples = s.voiceover.captured.get(skip..).ok_or_else(|| bad("audio.voiceover.stop", "invalid recording trim range"))?;
|
||||
if samples.is_empty() {
|
||||
s.voiceover.rec = None;
|
||||
s.voiceover.pending_stop = None;
|
||||
s.voiceover.captured.clear();
|
||||
return Ok(json!({"recording": false, "placed": false}));
|
||||
}
|
||||
let sample_count = samples.len();
|
||||
let sequence = s.project.sequence(rec.seq).ok_or(EngineError::NoSequence)?;
|
||||
if !sequence.audio_tracks.iter().any(|track| track.id == rec.track && !track.locked) {
|
||||
return Err(bad("audio.voiceover.stop", "the record track was deleted or locked; restore it before saving the take"));
|
||||
}
|
||||
// write the file
|
||||
let dir = record_dir(s, p);
|
||||
let base = s.prefs.voice_over.name.clone();
|
||||
|
|
@ -418,14 +460,15 @@ fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
k += 1;
|
||||
};
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("{dir}: {e}")))?;
|
||||
}
|
||||
let samples = s.voiceover.captured.get(skip..).ok_or_else(|| bad("audio.voiceover.stop", "invalid recording trim range"))?;
|
||||
let bytes = write_wav_f32(samples, sr as u32);
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// import + place as one undo step
|
||||
let n0 = s.history.undo.len();
|
||||
let item = crate::commands::import_bytes(s, &path, bytes.into(), None)?;
|
||||
let dur = Tick::from_units(samples.len() as i64, sr);
|
||||
let dur = Tick::from_units(sample_count as i64, sr);
|
||||
let at = rec.record_start;
|
||||
let track = rec.track;
|
||||
let rate = s.project.sequence(rec.seq).map(|q| q.settings.frame_rate).unwrap_or_default();
|
||||
|
|
@ -450,17 +493,20 @@ fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
});
|
||||
crate::clip_ops::collapse_history(s, n0, "Record Voice-over");
|
||||
let clip = clip?;
|
||||
let seq = s.project.sequence(rec.seq).ok_or(EngineError::NoSequence)?;
|
||||
let track_label = crate::mixer::strip_label(s.project.sequence(rec.seq).ok_or(EngineError::NoSequence)?, track);
|
||||
s.voiceover.rec = None;
|
||||
s.voiceover.pending_stop = None;
|
||||
s.voiceover.captured.clear();
|
||||
Ok(json!({
|
||||
"recording": false,
|
||||
"placed": true,
|
||||
"path": path,
|
||||
"item": item.0,
|
||||
"clip": clip.0,
|
||||
"track": crate::mixer::strip_label(seq, track),
|
||||
"track": track_label,
|
||||
"start": at.0,
|
||||
"duration": dur.0,
|
||||
"samples": samples.len(),
|
||||
"samples": sample_count,
|
||||
"sampleRate": sr,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,38 @@ fn track_clips(s: &Session, i: usize) -> Vec<filmcraft_project::TrackItem> {
|
|||
s.active_sequence().unwrap().audio_tracks[i].items.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_recording_save_keeps_the_take_for_retry() {
|
||||
let (mut s, track, index) = demo();
|
||||
s.execute("playhead.set", json!({"seconds":0})).unwrap();
|
||||
s.execute("audio.voiceover.start", json!({"track":track, "preroll":0})).unwrap();
|
||||
let dir = tmp("save-retry");
|
||||
std::fs::write(&dir, b"not a directory").unwrap();
|
||||
assert!(s.execute("audio.voiceover.stop", json!({"time":sec(1.0),"dir":dir})).is_err());
|
||||
assert!(s.voiceover.recording(), "failed save must retain the recording for retry");
|
||||
assert!(s.execute("audio.voiceover.sync", json!({"time":sec(1.5)})).is_err(), "playback must not discard an unsaved take");
|
||||
std::fs::remove_file(&dir).unwrap();
|
||||
let result = s.execute("audio.voiceover.stop", json!({"time":sec(2.0),"dir":dir})).unwrap();
|
||||
assert_eq!(result["samples"], 48000, "retry preserves the stopped take's end point");
|
||||
assert_eq!(result["placed"], true);
|
||||
assert!(!s.voiceover.recording());
|
||||
assert_eq!(track_clips(&s, index).len(), 1);
|
||||
let audio = mix(&s, 0, 48000);
|
||||
assert!(audio[0][0] > 0.49, "retry retained the first captured sample");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_record_points_and_preroll_are_bounded() {
|
||||
let (mut s, track, _) = demo();
|
||||
assert!(s.execute("audio.voiceover.start", json!({"track":track,"time":-1,"preroll":1e300})).is_err());
|
||||
assert!(!s.voiceover.recording());
|
||||
let result = s.execute("audio.voiceover.start", json!({"track":track,"time":sec(120.0),"preroll":1e300})).unwrap();
|
||||
assert_eq!(result["captureStart"], sec(60.0));
|
||||
assert!(result["cues"].as_array().unwrap().len() <= 61);
|
||||
s.execute("audio.voiceover.stop", json!({"discard":true})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_registered_and_disabled_without_a_recording() {
|
||||
for id in ["audio.voiceover.settings", "audio.voiceover.start", "audio.voiceover.sync", "audio.voiceover.stop"] {
|
||||
|
|
|
|||
|
|
@ -463,13 +463,18 @@ impl FilmcraftApp {
|
|||
self.frames.set_cache_budget(p.memory.frame_cache_mb as usize * (1 << 20));
|
||||
self.ui.play_after_render = p.timeline.play_after_rendering;
|
||||
if prev.as_ref().is_none_or(|q| q.audio_hardware != p.audio_hardware) {
|
||||
if let Some(input) = self.session.voiceover.input.as_mut() {
|
||||
input.configure_host(&p.audio_hardware.device_class);
|
||||
}
|
||||
let rate = self.session.active_sequence().map(|q| q.settings.sample_rate);
|
||||
let playing = self.playback.playing && self.playback.audio_clock;
|
||||
let playing = self.playback.playing && self.playback.preroll.is_none();
|
||||
if let Some(a) = self.audio.as_mut() {
|
||||
a.stop();
|
||||
a.configure(&p.audio_hardware, rate);
|
||||
}
|
||||
if playing {
|
||||
self.playback.anchor_tick = self.session.playhead();
|
||||
self.playback.anchor_time = ctx.input(|i| i.time);
|
||||
self.start_audio();
|
||||
}
|
||||
}
|
||||
|
|
@ -765,6 +770,18 @@ impl FilmcraftApp {
|
|||
}
|
||||
let rate = self.session.sequence_rate();
|
||||
let reading = if self.playback.audio_clock { self.audio.as_ref().and_then(|a| a.played_frames().map(|f| (f, a.sample_rate()))) } else { None };
|
||||
if self.playback.audio_clock && reading.is_none() {
|
||||
// A device error relinquishes its clock immediately. Resume from the displayed
|
||||
// frame, rather than jumping to the old wall-clock anchor after buffered playback.
|
||||
self.playback.anchor_tick = self.session.playhead();
|
||||
self.playback.anchor_time = now;
|
||||
self.playback.audio_clock = false;
|
||||
if let Some(audio) = self.audio.as_mut() {
|
||||
audio.stop();
|
||||
}
|
||||
log::warn!("audio output lost its playback clock; playing without sound");
|
||||
self.ui.status = "Audio output failed: playing without sound (check Settings ▸ Audio Hardware)".into();
|
||||
}
|
||||
if let Some((f, sr)) = reading {
|
||||
if f != self.playback.audio_seen.0 {
|
||||
self.playback.audio_seen = (f, now);
|
||||
|
|
@ -1582,3 +1599,81 @@ mod gpu_fallback_tests {
|
|||
assert_eq!(super::plan_side(&p), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod audio_recovery_tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
struct Device {
|
||||
ready: bool,
|
||||
starts: Arc<AtomicUsize>,
|
||||
reading: Option<u64>,
|
||||
}
|
||||
|
||||
impl AudioOut for Device {
|
||||
fn sample_rate(&self) -> u32 {
|
||||
48000
|
||||
}
|
||||
fn channels(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn configure(&mut self, hardware: &filmcraft_engine::settings::AudioHardwarePrefs, _: Option<u32>) {
|
||||
self.ready = hardware.default_output == "available";
|
||||
}
|
||||
fn start(&mut self, _: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||||
self.starts.fetch_add(1, Ordering::Relaxed);
|
||||
if self.ready { Ok(48000) } else { Err("device unavailable".into()) }
|
||||
}
|
||||
fn stop(&mut self) {}
|
||||
fn played_frames(&self) -> Option<u64> {
|
||||
self.reading
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_hardware_recovers_wall_clock_playback_without_rewinding() {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
let starts = Arc::new(AtomicUsize::new(0));
|
||||
app.audio = Some(Box::new(Device { ready: false, starts: starts.clone(), reading: Some(0) }));
|
||||
app.apply_prefs(&ctx);
|
||||
app.play(1.0);
|
||||
app.end_preroll(0.0);
|
||||
assert!(!app.playback.audio_clock);
|
||||
app.session.set_playhead(Tick::from_seconds_f64(2.0));
|
||||
app.session.prefs.audio_hardware.default_output = "available".into();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(starts.load(Ordering::Relaxed), 2);
|
||||
assert!(app.playback.audio_clock);
|
||||
assert_eq!(app.playback.anchor_tick, app.session.playhead());
|
||||
app.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_device_clock_resumes_from_the_displayed_frame() {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
app.audio = Some(Box::new(Device { ready: true, starts: Arc::new(AtomicUsize::new(0)), reading: None }));
|
||||
let displayed = app.session.sequence_rate().tick_of(48);
|
||||
app.session.set_playhead(displayed);
|
||||
app.playback.playing = true;
|
||||
app.playback.audio_clock = true;
|
||||
app.playback.anchor_tick = Tick::ZERO;
|
||||
app.playback.anchor_time = 0.0;
|
||||
let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx()));
|
||||
output.textures_delta.clear();
|
||||
assert!(!app.playback.audio_clock);
|
||||
assert_eq!(app.playback.anchor_tick, displayed);
|
||||
assert_eq!(app.session.playhead(), displayed);
|
||||
assert!(app.ui.status.contains("Audio output failed"));
|
||||
app.stop();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
let mut close = false;
|
||||
let mut apply = false;
|
||||
let mut discard = false;
|
||||
let accent = app.tokens.accent;
|
||||
egui::Window::new("Voice-Over Record Settings").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
|
||||
egui::Grid::new("voiceover-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
|
|
@ -243,6 +244,7 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
ui.end_row();
|
||||
ui.label("Source:");
|
||||
let shown = if d.prefs.source.is_empty() { "Default Input".to_string() } else { d.prefs.source.clone() };
|
||||
let previous_source = d.prefs.source.clone();
|
||||
let cb = egui::ComboBox::from_id_salt("voiceover-source").selected_text(&shown).width(220.0).show_ui(ui, |ui| {
|
||||
let mut opts = vec![(String::new(), "Default Input".to_string())];
|
||||
opts.extend(d.devices.iter().map(|x| (x.clone(), x.clone())));
|
||||
|
|
@ -255,9 +257,17 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
});
|
||||
elems.push(("voiceover.source".into(), cb.response.rect, shown));
|
||||
if d.prefs.source != previous_source {
|
||||
let device = if d.prefs.source.is_empty() { app.session.prefs.audio_hardware.default_input.as_str() } else { d.prefs.source.as_str() };
|
||||
if let Some(input) = app.session.voiceover.input.as_ref() {
|
||||
d.channels = input.channels(device);
|
||||
d.prefs.input_channel = d.prefs.input_channel.min(u32::from(d.channels.saturating_sub(1)));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Input:");
|
||||
let n = d.channels.max(1) as u32;
|
||||
let n = u32::from(d.channels.clamp(1, 64));
|
||||
d.prefs.input_channel = d.prefs.input_channel.min(63);
|
||||
let cur = format!("Channel {}", d.prefs.input_channel + 1);
|
||||
let cb = egui::ComboBox::from_id_salt("voiceover-input").selected_text(&cur).width(220.0).show_ui(ui, |ui| {
|
||||
for c in 0..n.max(d.prefs.input_channel + 1) {
|
||||
|
|
@ -291,6 +301,13 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if app.session.voiceover.recording() {
|
||||
let r = ui.button("Discard take");
|
||||
elems.push(("voiceover.discard".into(), r.rect, "Discard the active take".into()));
|
||||
if r.clicked() {
|
||||
discard = true;
|
||||
}
|
||||
}
|
||||
let c = ui.button("Cancel");
|
||||
elems.push(("voiceover.cancel".into(), c.rect, "Cancel".into()));
|
||||
if c.clicked() {
|
||||
|
|
@ -306,6 +323,18 @@ fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if discard {
|
||||
match app.session.execute("audio.voiceover.stop", json!({"discard":true})) {
|
||||
Ok(_) => {
|
||||
if app.playback.playing {
|
||||
app.stop();
|
||||
}
|
||||
app.ui.status = "Voice-over take discarded".into();
|
||||
close = true;
|
||||
}
|
||||
Err(error) => app.ui.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,23 @@ impl Driver {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_take_can_be_discarded_from_the_settings_dialog() {
|
||||
let mut d = Driver::demo();
|
||||
d.exec("audio.voiceover.start", json!({"track":"A1","time":0,"preroll":0}));
|
||||
let blocker = std::env::temp_dir().join(format!("filmcraft-voiceover-ui-blocker-{}", std::process::id()));
|
||||
std::fs::write(&blocker, b"not a directory").unwrap();
|
||||
let failure = d.call("engine.execute", json!({"command":"audio.voiceover.stop","params":{"seconds":1,"dir":blocker}}));
|
||||
assert_eq!(failure["ok"], false);
|
||||
assert!(d.harness.state().session.voiceover.recording());
|
||||
d.ok("ui.menu.invoke", json!({"id":"voiceover.settingsDialog"}));
|
||||
d.frames(4); // Allow the newly anchored window to settle before using its button rectangle.
|
||||
d.click("voiceover.discard");
|
||||
assert!(!d.harness.state().session.voiceover.recording(), "{}", d.harness.state().ui.status);
|
||||
d.exec("file.newProject", json!({}));
|
||||
std::fs::remove_file(blocker).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_dialog_from_the_track_header_persists_on_ok() {
|
||||
let mut d = Driver::demo();
|
||||
|
|
|
|||
Loading…
Reference in New Issue