M7.8: audio parity — 5.1 multichannel, voice-over record, Remix, Clip Mixer automation
5.1 / multichannel:
- audio-dsp::channels: layouts (L R C LFE Ls Rs), ITU-R BS.775 up/downmix, 5.1 Mixdown Types,
equal-power 5.1 panner (puck, Center %, LFE).
- Mixer graph buses are 2 or 6 channels wide; 5.1 tracks/submixes/Mix; strips feeding a 5.1 bus
use the 5.1 panner (lanes pan51.x/y/center/lfe stored in MixerStrip::lanes, no schema change);
5.1 strips fold into stereo buses with BS.775; per-channel meters; clip effects on 6 channels.
- file.newSequence {mix, trackType}, sequence.settings {mix}; playback honours the 5.1 Mixdown
Type preference on stereo devices and plays 6 channels on 5.1 devices.
- Export Audio Channels 5.1: WAV WAVE_FORMAT_EXTENSIBLE (mask 0x3F), QuickTime PCM with a chan
atom, AAC channel configuration 6 (reordered to C L R Ls Rs LFE).
- Track Mixer: 5.1 panner puck with Center/LFE, N-channel meters, Show/Hide Tracks, Meter
Input(s) Only; transport record button runs voice-over.
Voice-over: engine AudioInput trait + SyntheticInput, cpal input in the app, audio.voiceover.*
commands (punch-in, pre/post-roll, countdown cues, WAV next to project / scratch disk, one undo
step), track header mic button and Voice-Over Record Settings dialog.
Remix: beat tracking + chroma/cepstra self-similarity planner in audio-dsp, hidden `remix` clip
effect holding the plan, render::remix playback with equal-power crossfades, clip.remix.*
commands, Remix tool and Remix Properties dialog.
Clip Mixer automation: per-track Latch/Touch/Write (clipMixer.setMode/touch/release) heard live
and written as thinned clip Volume/Panner keyframes at the end of the pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2323218446
commit
1fa560c1c7
|
|
@ -0,0 +1,139 @@
|
|||
//! cpal audio input for voice-over recording ([`filmcraft_engine::voiceover::AudioInput`]).
|
||||
//!
|
||||
//! The stream lives on its own thread (cpal streams are not `Send` on every backend); the
|
||||
//! callback de-interleaves into a shared planar buffer that the engine drains when recording stops.
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use filmcraft_engine::voiceover::{AudioInput, InputFormat};
|
||||
|
||||
pub struct CpalIn {
|
||||
/// Host name (Settings ▸ Audio Hardware ▸ Device Class; empty = default host).
|
||||
host: String,
|
||||
buf: Arc<Mutex<Vec<Vec<f32>>>>,
|
||||
stop: Option<mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
impl CpalIn {
|
||||
pub fn new(host: &str) -> Self {
|
||||
Self { host: host.to_string(), buf: Arc::default(), stop: None }
|
||||
}
|
||||
}
|
||||
|
||||
fn host(name: &str) -> cpal::Host {
|
||||
if !name.is_empty()
|
||||
&& let Some(id) = cpal::available_hosts().into_iter().find(|h| h.name() == name)
|
||||
&& let Ok(h) = cpal::host_from_id(id)
|
||||
{
|
||||
return h;
|
||||
}
|
||||
cpal::default_host()
|
||||
}
|
||||
|
||||
fn device(h: &cpal::Host, name: &str) -> Option<cpal::Device> {
|
||||
if !name.is_empty()
|
||||
&& let Ok(mut devs) = h.input_devices()
|
||||
&& let Some(d) = devs.find(|d| d.name().is_ok_and(|n| n == name))
|
||||
{
|
||||
return Some(d);
|
||||
}
|
||||
h.default_input_device()
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
impl AudioInput for CpalIn {
|
||||
fn devices(&self) -> Vec<String> {
|
||||
host(&self.host).input_devices().map(|d| d.filter_map(|x| x.name().ok()).collect()).unwrap_or_default()
|
||||
}
|
||||
fn channels(&self, name: &str) -> u16 {
|
||||
device(&host(&self.host), name).and_then(|d| d.default_input_config().ok()).map(|c| c.channels()).unwrap_or(0)
|
||||
}
|
||||
fn start(&mut self, name: &str, sample_rate: u32) -> Result<InputFormat, String> {
|
||||
self.stop();
|
||||
lock(&self.buf).clear();
|
||||
let (host_name, name) = (self.host.clone(), name.to_string());
|
||||
let buf = self.buf.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,
|
||||
)
|
||||
.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);
|
||||
}
|
||||
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())??;
|
||||
self.stop = Some(stop_tx);
|
||||
Ok(fmt)
|
||||
}
|
||||
fn read(&mut self, frames: usize) -> Vec<Vec<f32>> {
|
||||
let mut b = lock(&self.buf);
|
||||
b.iter_mut()
|
||||
.map(|c| {
|
||||
let n = frames.min(c.len());
|
||||
c.drain(..n).collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn discard(&mut self) {
|
||||
lock(&self.buf).iter_mut().for_each(Vec::clear);
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
if let Some(tx) = self.stop.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
mod app_nap;
|
||||
mod audio;
|
||||
mod audio_in;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
|
@ -96,6 +97,8 @@ fn main() -> eframe::Result {
|
|||
eprintln!("filmcraft: auto-save and crash recovery unavailable: {e}");
|
||||
}
|
||||
}
|
||||
// voice-over recording reads the microphone through cpal
|
||||
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
|
||||
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
//! Channel layouts, format conversion (up/downmix) and the 5.1 surround panner.
|
||||
//!
|
||||
//! **Channel order.** 5.1 audio is always in the SMPTE / WAVE order **L, R, C, LFE, Ls, Rs**
|
||||
//! ([`L`], [`R`], [`C`], [`LFE`], [`LS`], [`RS`]); stereo is L, R; mono is one channel. Codecs with a
|
||||
//! different native order (AAC: C, L, R, Ls, Rs, LFE) reorder at the encoder boundary.
|
||||
//!
|
||||
//! **Downmix** follows ITU-R BS.775 (Table 2, "3/2 source" rows), with `k = 1/√2` (−3 dB):
|
||||
//!
|
||||
//! | from → to | equations |
|
||||
//! |---|---|
|
||||
//! | 5.1 → 2.0 | `Lo = L + k·C + k·Ls`, `Ro = R + k·C + k·Rs` (LFE omitted) |
|
||||
//! | 5.1 → 1.0 | `M = k·L + k·R + C + ½·Ls + ½·Rs` |
|
||||
//! | 2.0 → 1.0 | `M = k·L + k·R` |
|
||||
//!
|
||||
//! The stereo downmix used for playback on stereo devices can be changed by the "5.1 Mixdown Type"
|
||||
//! preference ([`Mixdown`]): Front Only drops the surrounds, the `+ LFE` variants add `k·LFE` to
|
||||
//! both sides.
|
||||
//!
|
||||
//! **Upmix** places the source on the front speakers without inventing content: mono → C (5.1) or
|
||||
//! both sides at unity (2.0, as mono clips are played everywhere else in FilmCraft); stereo → L, R.
|
||||
//!
|
||||
//! **5.1 panner** ([`Pan51`]): a puck at `(x, y)` in the listening square (x −1 left … +1 right,
|
||||
//! y −1 rear … +1 front). A point source at `(x, y)` is split front/rear and left/right with the
|
||||
//! sine/cosine constant-power law, and the front image is shared between the phantom L/R pair and
|
||||
//! the centre speaker by **Center %** (`k_c = center · (1 − |x|)` of the front power goes to C). The
|
||||
//! gains of a point source always have unit power (`Σ g² = 1`). Multichannel sources keep their
|
||||
//! layout around the puck: each source channel sits at its nominal speaker position moved by the
|
||||
//! puck's offset from front-centre (so the default puck (0, 1) at 100 % centre maps 5.1 to itself
|
||||
//! exactly). Stereo sources sit at the puck ± 1 in x. The LFE output is the source LFE (5.1) or
|
||||
//! the mean of the source channels (mono/stereo) times the **LFE** gain.
|
||||
|
||||
use std::f32::consts::{FRAC_1_SQRT_2, FRAC_PI_2, FRAC_PI_4};
|
||||
|
||||
/// −3 dB, the BS.775 downmix coefficient.
|
||||
pub const K: f32 = FRAC_1_SQRT_2;
|
||||
|
||||
/// 5.1 channel indices (SMPTE / WAVE order).
|
||||
pub const L: usize = 0;
|
||||
pub const R: usize = 1;
|
||||
pub const C: usize = 2;
|
||||
pub const LFE: usize = 3;
|
||||
pub const LS: usize = 4;
|
||||
pub const RS: usize = 5;
|
||||
|
||||
/// Short names of the 5.1 channels in order.
|
||||
pub const NAMES_51: [&str; 6] = ["L", "R", "C", "LFE", "Ls", "Rs"];
|
||||
|
||||
/// A channel layout.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Layout {
|
||||
Mono,
|
||||
#[default]
|
||||
Stereo,
|
||||
/// 5.1 in L, R, C, LFE, Ls, Rs order.
|
||||
Surround51,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn channels(self) -> usize {
|
||||
match self {
|
||||
Layout::Mono => 1,
|
||||
Layout::Stereo => 2,
|
||||
Layout::Surround51 => 6,
|
||||
}
|
||||
}
|
||||
/// The layout of a plain channel count (1, 2 or 6; anything else is treated as stereo).
|
||||
pub fn from_channels(n: usize) -> Layout {
|
||||
match n {
|
||||
1 => Layout::Mono,
|
||||
6 => Layout::Surround51,
|
||||
_ => Layout::Stereo,
|
||||
}
|
||||
}
|
||||
/// Channel names for display ("L", "R" …).
|
||||
pub fn names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Layout::Mono => &["M"],
|
||||
Layout::Stereo => &["L", "R"],
|
||||
Layout::Surround51 => &NAMES_51,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premiere's "5.1 Mixdown Type" (Preferences ▸ Audio): how 5.1 is folded to stereo for playback.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Mixdown {
|
||||
/// L, R and C only.
|
||||
Front,
|
||||
/// L, R, C and the surrounds (ITU-R BS.775).
|
||||
#[default]
|
||||
FrontRear,
|
||||
/// L, R, C and LFE.
|
||||
FrontLfe,
|
||||
/// Everything.
|
||||
FrontRearLfe,
|
||||
}
|
||||
|
||||
impl Mixdown {
|
||||
pub const ALL: [Mixdown; 4] = [Mixdown::Front, Mixdown::FrontRear, Mixdown::FrontLfe, Mixdown::FrontRearLfe];
|
||||
/// Preference value (`front`, `frontRear`, `frontLfe`, `frontRearLfe`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Mixdown::Front => "front",
|
||||
Mixdown::FrontRear => "frontRear",
|
||||
Mixdown::FrontLfe => "frontLfe",
|
||||
Mixdown::FrontRearLfe => "frontRearLfe",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<Mixdown> {
|
||||
Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
fn rear(self) -> bool {
|
||||
matches!(self, Mixdown::FrontRear | Mixdown::FrontRearLfe)
|
||||
}
|
||||
fn lfe(self) -> bool {
|
||||
matches!(self, Mixdown::FrontLfe | Mixdown::FrontRearLfe)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversion matrix `m[out][in]` from `from` to `to`. Stereo downmixes of 5.1 use `mixdown`
|
||||
/// ([`Mixdown::FrontRear`] is BS.775).
|
||||
pub fn matrix(from: Layout, to: Layout, mixdown: Mixdown) -> Vec<Vec<f32>> {
|
||||
let (ni, no) = (from.channels(), to.channels());
|
||||
let mut m = vec![vec![0.0f32; ni]; no];
|
||||
match (from, to) {
|
||||
(a, b) if a == b => {
|
||||
for (i, row) in m.iter_mut().enumerate() {
|
||||
row[i] = 1.0;
|
||||
}
|
||||
}
|
||||
(Layout::Mono, Layout::Stereo) => {
|
||||
m[0][0] = 1.0;
|
||||
m[1][0] = 1.0;
|
||||
}
|
||||
(Layout::Mono, Layout::Surround51) => m[C][0] = 1.0,
|
||||
(Layout::Stereo, Layout::Mono) => m[0] = vec![K, K],
|
||||
(Layout::Stereo, Layout::Surround51) => {
|
||||
m[L][0] = 1.0;
|
||||
m[R][1] = 1.0;
|
||||
}
|
||||
(Layout::Surround51, Layout::Mono) => {
|
||||
m[0][L] = K;
|
||||
m[0][R] = K;
|
||||
m[0][C] = 1.0;
|
||||
m[0][LS] = 0.5;
|
||||
m[0][RS] = 0.5;
|
||||
}
|
||||
(Layout::Surround51, Layout::Stereo) => {
|
||||
m[0][L] = 1.0;
|
||||
m[1][R] = 1.0;
|
||||
m[0][C] = K;
|
||||
m[1][C] = K;
|
||||
if mixdown.rear() {
|
||||
m[0][LS] = K;
|
||||
m[1][RS] = K;
|
||||
}
|
||||
if mixdown.lfe() {
|
||||
m[0][LFE] = K;
|
||||
m[1][LFE] = K;
|
||||
}
|
||||
}
|
||||
_ => unreachable!("all layout pairs are covered"),
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Apply a matrix `m[out][in]` to planar `input` (all channels the same length).
|
||||
pub fn apply_matrix(m: &[Vec<f32>], input: &[&[f32]]) -> Vec<Vec<f32>> {
|
||||
let n = input.first().map_or(0, |c| c.len());
|
||||
m.iter()
|
||||
.map(|row| {
|
||||
let mut out = vec![0.0f32; n];
|
||||
for (g, x) in row.iter().zip(input) {
|
||||
if *g == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (o, s) in out.iter_mut().zip(x.iter()) {
|
||||
*o += g * s;
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert planar audio from one layout to another ([`matrix`]).
|
||||
pub fn convert(input: &[&[f32]], from: Layout, to: Layout, mixdown: Mixdown) -> Vec<Vec<f32>> {
|
||||
if from == to {
|
||||
return input.iter().map(|c| c.to_vec()).collect();
|
||||
}
|
||||
apply_matrix(&matrix(from, to, mixdown), input)
|
||||
}
|
||||
|
||||
/// Convert owned planar audio whose layout is given by its channel count.
|
||||
pub fn convert_owned(input: Vec<Vec<f32>>, to: Layout, mixdown: Mixdown) -> Vec<Vec<f32>> {
|
||||
let from = Layout::from_channels(input.len());
|
||||
if from == to && input.len() == to.channels() {
|
||||
return input;
|
||||
}
|
||||
let refs: Vec<&[f32]> = input.iter().map(Vec::as_slice).collect();
|
||||
if input.len() != from.channels() {
|
||||
// unusual channel counts: take the first channels that fit, pad with silence
|
||||
let n = input.first().map_or(0, Vec::len);
|
||||
let mut v: Vec<Vec<f32>> = input.iter().take(to.channels()).cloned().collect();
|
||||
v.resize(to.channels(), vec![0.0; n]);
|
||||
return v;
|
||||
}
|
||||
convert(&refs, from, to, mixdown)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- 5.1 panner
|
||||
|
||||
/// The 5.1 panner of a track or send feeding a 5.1 bus.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Pan51 {
|
||||
/// −1 (left) … +1 (right).
|
||||
pub x: f32,
|
||||
/// −1 (rear) … +1 (front).
|
||||
pub y: f32,
|
||||
/// Center %: 0 … 1, the share of the front image the centre speaker takes at x = 0.
|
||||
pub center: f32,
|
||||
/// LFE gain (linear).
|
||||
pub lfe: f32,
|
||||
}
|
||||
|
||||
impl Default for Pan51 {
|
||||
/// Front centre, 100 % centre, LFE at unity.
|
||||
fn default() -> Self {
|
||||
Pan51 { x: 0.0, y: 1.0, center: 1.0, lfe: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Gains of a point source at `(x, y)` to the six 5.1 outputs (LFE is 0). `Σ g² = 1`.
|
||||
pub fn point_gains(x: f32, y: f32, center: f32) -> [f32; 6] {
|
||||
let x = x.clamp(-1.0, 1.0);
|
||||
let y = y.clamp(-1.0, 1.0);
|
||||
let fb = (1.0 - y) * 0.5 * FRAC_PI_2;
|
||||
let (f, b) = (fb.cos(), fb.sin());
|
||||
let lr = (x + 1.0) * FRAC_PI_4;
|
||||
let (gl, gr) = (lr.cos(), lr.sin());
|
||||
let kc = (center.clamp(0.0, 1.0) * (1.0 - x.abs())).clamp(0.0, 1.0);
|
||||
let ph = (1.0 - kc).sqrt();
|
||||
let mut g = [0.0f32; 6];
|
||||
g[L] = f * ph * gl;
|
||||
g[R] = f * ph * gr;
|
||||
g[C] = f * kc.sqrt();
|
||||
g[LS] = b * gl;
|
||||
g[RS] = b * gr;
|
||||
// f32 sin/cos at the quadrant ends are not exactly 0 / 1: snap them so speaker positions
|
||||
// route bit-exactly (a 5.1 source through the default panner is the identity)
|
||||
for v in g.iter_mut() {
|
||||
if v.abs() < 1e-6 {
|
||||
*v = 0.0;
|
||||
} else if (*v - 1.0).abs() < 1e-6 {
|
||||
*v = 1.0;
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Nominal source-channel positions relative to the puck's offset from front-centre.
|
||||
fn offsets(from: Layout) -> &'static [(f32, f32)] {
|
||||
match from {
|
||||
Layout::Mono => &[(0.0, 0.0)],
|
||||
Layout::Stereo => &[(-1.0, 0.0), (1.0, 0.0)],
|
||||
// L, R, C, LFE (not positioned), Ls, Rs
|
||||
Layout::Surround51 => &[(-1.0, 0.0), (1.0, 0.0), (0.0, 0.0), (0.0, 0.0), (-1.0, -2.0), (1.0, -2.0)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Panning matrix `m[out][in]` (6 outputs) of a `from` source through the 5.1 panner.
|
||||
pub fn pan51_matrix(from: Layout, p: Pan51) -> Vec<Vec<f32>> {
|
||||
let ni = from.channels();
|
||||
let mut m = vec![vec![0.0f32; ni]; 6];
|
||||
for (i, (ox, oy)) in offsets(from).iter().enumerate() {
|
||||
if from == Layout::Surround51 && i == LFE {
|
||||
m[LFE][i] = p.lfe;
|
||||
continue;
|
||||
}
|
||||
let g = point_gains(p.x + ox, p.y + oy, p.center);
|
||||
for (o, row) in m.iter_mut().enumerate() {
|
||||
row[i] = g[o];
|
||||
}
|
||||
if from != Layout::Surround51 {
|
||||
m[LFE][i] = p.lfe / ni as f32;
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// Peak level (linear) per channel.
|
||||
pub fn peaks(input: &[&[f32]]) -> Vec<f32> {
|
||||
input.iter().map(|c| c.iter().fold(0f32, |a, x| a.max(x.abs()))).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bs775_downmix_coefficients_are_exact() {
|
||||
let k = std::f32::consts::FRAC_1_SQRT_2;
|
||||
// 5.1 → 2.0 (L, R, C, LFE, Ls, Rs)
|
||||
let m = matrix(Layout::Surround51, Layout::Stereo, Mixdown::FrontRear);
|
||||
assert_eq!(m[0], vec![1.0, 0.0, k, 0.0, k, 0.0]);
|
||||
assert_eq!(m[1], vec![0.0, 1.0, k, 0.0, 0.0, k]);
|
||||
// 5.1 → 1.0
|
||||
let m = matrix(Layout::Surround51, Layout::Mono, Mixdown::FrontRear);
|
||||
assert_eq!(m[0], vec![k, k, 1.0, 0.0, 0.5, 0.5]);
|
||||
// 2.0 → 1.0
|
||||
assert_eq!(matrix(Layout::Stereo, Layout::Mono, Mixdown::FrontRear)[0], vec![k, k]);
|
||||
// mixdown types
|
||||
let f = matrix(Layout::Surround51, Layout::Stereo, Mixdown::Front);
|
||||
assert_eq!(f[0], vec![1.0, 0.0, k, 0.0, 0.0, 0.0]);
|
||||
let fl = matrix(Layout::Surround51, Layout::Stereo, Mixdown::FrontLfe);
|
||||
assert_eq!(fl[1], vec![0.0, 1.0, k, k, 0.0, 0.0]);
|
||||
let all = matrix(Layout::Surround51, Layout::Stereo, Mixdown::FrontRearLfe);
|
||||
assert_eq!(all[0], vec![1.0, 0.0, k, k, k, 0.0]);
|
||||
assert_eq!(Mixdown::from_id("frontRearLfe"), Some(Mixdown::FrontRearLfe));
|
||||
assert_eq!(Mixdown::default().id(), "frontRear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upmix_places_on_front_speakers() {
|
||||
assert_eq!(matrix(Layout::Mono, Layout::Surround51, Mixdown::FrontRear), vec![vec![0.0], vec![0.0], vec![1.0], vec![0.0], vec![0.0], vec![0.0]]);
|
||||
let s = matrix(Layout::Stereo, Layout::Surround51, Mixdown::FrontRear);
|
||||
assert_eq!(s[L], vec![1.0, 0.0]);
|
||||
assert_eq!(s[R], vec![0.0, 1.0]);
|
||||
assert!(s[C..].iter().all(|r| r.iter().all(|g| *g == 0.0)));
|
||||
assert_eq!(matrix(Layout::Mono, Layout::Stereo, Mixdown::FrontRear), vec![vec![1.0], vec![1.0]]);
|
||||
// identity
|
||||
for l in [Layout::Mono, Layout::Stereo, Layout::Surround51] {
|
||||
let m = matrix(l, l, Mixdown::Front);
|
||||
for (o, row) in m.iter().enumerate() {
|
||||
for (i, g) in row.iter().enumerate() {
|
||||
assert_eq!(*g, if o == i { 1.0 } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_applies_the_matrix_per_sample() {
|
||||
let ch: Vec<Vec<f32>> = (0..6).map(|c| vec![(c + 1) as f32 * 0.1; 4]).collect();
|
||||
let refs: Vec<&[f32]> = ch.iter().map(Vec::as_slice).collect();
|
||||
let st = convert(&refs, Layout::Surround51, Layout::Stereo, Mixdown::FrontRear);
|
||||
let k = K;
|
||||
assert!(close(st[0][3], 0.1 + k * 0.3 + k * 0.5));
|
||||
assert!(close(st[1][0], 0.2 + k * 0.3 + k * 0.6));
|
||||
let back = convert_owned(st.clone(), Layout::Stereo, Mixdown::FrontRear);
|
||||
assert_eq!(back, st);
|
||||
let up = convert_owned(vec![vec![0.5; 3]], Layout::Surround51, Mixdown::FrontRear);
|
||||
assert_eq!(up.len(), 6);
|
||||
assert_eq!(up[C], vec![0.5; 3]);
|
||||
assert_eq!(up[L], vec![0.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panner_point_gains_are_equal_power() {
|
||||
for xi in -10..=10 {
|
||||
for yi in -10..=10 {
|
||||
for c in [0.0, 0.3, 1.0] {
|
||||
let g = point_gains(xi as f32 / 10.0, yi as f32 / 10.0, c);
|
||||
let p: f32 = g.iter().map(|v| v * v).sum();
|
||||
assert!((p - 1.0).abs() < 1e-5, "({xi}, {yi}, {c}) power {p}");
|
||||
assert_eq!(g[LFE], 0.0);
|
||||
assert!(g.iter().all(|v| *v >= -1e-7));
|
||||
}
|
||||
}
|
||||
}
|
||||
let k = K;
|
||||
// speaker positions
|
||||
let at = |x, y, c| point_gains(x, y, c);
|
||||
assert!(close(at(0.0, 1.0, 1.0)[C], 1.0));
|
||||
assert!(close(at(-1.0, 1.0, 1.0)[L], 1.0));
|
||||
assert!(close(at(1.0, 1.0, 0.5)[R], 1.0));
|
||||
assert!(close(at(-1.0, -1.0, 1.0)[LS], 1.0));
|
||||
assert!(close(at(1.0, -1.0, 1.0)[RS], 1.0));
|
||||
// phantom centre at 0 % centre: −3 dB each side
|
||||
let g = at(0.0, 1.0, 0.0);
|
||||
assert!(close(g[L], k) && close(g[R], k) && g[C] == 0.0);
|
||||
// room centre: −3 dB front/rear, then −3 dB left/right
|
||||
let g = at(0.0, 0.0, 0.0);
|
||||
assert!(close(g[L], 0.5) && close(g[R], 0.5) && close(g[LS], 0.5) && close(g[RS], 0.5));
|
||||
// 50 % centre at front centre: half the power to C
|
||||
let g = at(0.0, 1.0, 0.5);
|
||||
assert!(close(g[C] * g[C], 0.5) && close(g[L] * g[L] + g[R] * g[R], 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panner_matrices() {
|
||||
// 5.1 through the default panner is the identity
|
||||
let m = pan51_matrix(Layout::Surround51, Pan51::default());
|
||||
for (o, row) in m.iter().enumerate() {
|
||||
for (i, g) in row.iter().enumerate() {
|
||||
assert!(close(*g, if o == i { 1.0 } else { 0.0 }), "out {o} in {i} = {g}");
|
||||
}
|
||||
}
|
||||
// stereo at front centre: L → L, R → R
|
||||
let m = pan51_matrix(Layout::Stereo, Pan51 { lfe: 0.0, ..Pan51::default() });
|
||||
assert!(close(m[L][0], 1.0) && close(m[R][1], 1.0) && m[C][0].abs() < 1e-6 && m[LFE][0] == 0.0);
|
||||
// mono at the default puck: centre only; LFE gain = mean of inputs
|
||||
let m = pan51_matrix(Layout::Mono, Pan51 { lfe: 0.5, ..Pan51::default() });
|
||||
assert!(close(m[C][0], 1.0) && close(m[LFE][0], 0.5));
|
||||
let m = pan51_matrix(Layout::Stereo, Pan51 { lfe: 0.5, ..Pan51::default() });
|
||||
assert!(close(m[LFE][0], 0.25) && close(m[LFE][1], 0.25));
|
||||
// every source channel keeps unit power (LFE aside)
|
||||
for l in [Layout::Mono, Layout::Stereo, Layout::Surround51] {
|
||||
let p = Pan51 { x: 0.37, y: -0.21, center: 0.6, lfe: 0.0 };
|
||||
let m = pan51_matrix(l, p);
|
||||
for i in 0..l.channels() {
|
||||
if l == Layout::Surround51 && i == LFE {
|
||||
continue;
|
||||
}
|
||||
let pw: f32 = (0..6).map(|o| m[o][i] * m[o][i]).sum();
|
||||
assert!((pw - 1.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
// puck hard rear-left: 5.1 front channels collapse towards the rear left
|
||||
let m = pan51_matrix(Layout::Surround51, Pan51 { x: -1.0, y: -1.0, center: 1.0, lfe: 1.0 });
|
||||
assert!(close(m[LS][L], 1.0) && close(m[LS][C], 1.0) && close(m[LS][LS], 1.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
//! engine and UI can build controls generically.
|
||||
//! * [`biquad`] — RBJ-cookbook biquads (TDF-II) with analytic magnitude response.
|
||||
//! * [`sync`] — offset between two recordings of one event (GCC-PHAT, sample-accurate).
|
||||
//! * [`channels`] — channel layouts, ITU-R BS.775 up/downmix, 5.1 mixdown types, the 5.1 panner.
|
||||
//!
|
||||
//! Conventions: audio is **planar f32** (`&mut [&mut [f32]]`, one slice per channel, all the same
|
||||
//! length). `process` never allocates; all buffers are sized at construction. Parameters may be
|
||||
|
|
@ -16,12 +17,14 @@
|
|||
//! portable to wasm).
|
||||
|
||||
pub mod biquad;
|
||||
pub mod channels;
|
||||
pub mod design;
|
||||
pub mod ducking;
|
||||
pub mod effects;
|
||||
pub mod fft;
|
||||
pub mod loudness;
|
||||
pub mod oversample;
|
||||
pub mod remix;
|
||||
mod smooth;
|
||||
pub mod sync;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,859 @@
|
|||
//! Music remix: retime a piece of music to a target duration by cutting it at musically similar
|
||||
//! beat boundaries (Clip ▸ Remix).
|
||||
//!
|
||||
//! ```text
|
||||
//! mono mix ─► STFT (≈46 ms Hann, ¼ hop) ─► log-magnitude spectral flux = onset envelope
|
||||
//! ─► tempo: autocorrelation of the envelope, 50–200 BPM, log-Gaussian prior at 120 BPM
|
||||
//! ─► beats: dynamic programming (onset strength + penalty on beat-to-beat deviation
|
||||
//! from the period, squared log ratio), backtracked from the strongest end
|
||||
//! ─► per beat: 12-bin chroma and 12 mel cepstra (26 mel bands, DCT-II, c0 dropped)
|
||||
//! ─► beat self-similarity S(i, j) = 0.6 · cos(chroma) + 0.4 · (1 + cos(z-scored cepstra)) / 2
|
||||
//! plan: output = piece₀ ⧺ piece₁ ⧺ … ⧺ piece_k; each joint leaves the source at a beat boundary
|
||||
//! b_i and continues at another beat boundary b_j, chosen where the context of the jump
|
||||
//! (the beats before and after) is most similar: J(i, j) = mean of S along the diagonals
|
||||
//! through (i, j) over `CONTEXT` beats on both sides.
|
||||
//! render: pieces concatenated with an equal-power crossfade centred on every joint.
|
||||
//! ```
|
||||
//!
|
||||
//! **Parameters** (Premiere's Remix "Segments" and "Variations" sliders; our own semantics):
|
||||
//!
|
||||
//! | parameter | range | default | meaning |
|
||||
//! |---|---|---|---|
|
||||
//! | segments | 0 … 100 | 50 | number of joints: 0–33 → 1, 34–67 → 2, 68–100 → 3 (more when a long extension needs them) |
|
||||
//! | variations | 0 … 100 | 50 | how far from the evenly spaced positions a joint may move to find a better match: ±(1 + 7·v/100) beats |
|
||||
//!
|
||||
//! The first piece starts at the beginning of the source and the last ends at its end (intro and
|
||||
//! outro are kept). The plan length is within half a beat of the target whenever the beat grid
|
||||
//! allows it, always within one beat. Everything is deterministic: the same audio, target and
|
||||
//! parameters give the same plan.
|
||||
//!
|
||||
//! Positions are in samples of the analysed signal (`i64`).
|
||||
|
||||
use crate::fft::Fft;
|
||||
|
||||
/// Beats of context on each side of a joint that the similarity looks at.
|
||||
pub const CONTEXT: usize = 4;
|
||||
/// Shortest piece, in beats (no stutter edits).
|
||||
pub const MIN_PIECE_BEATS: usize = 4;
|
||||
/// Crossfade at each joint (seconds).
|
||||
pub const XFADE_S: f64 = 0.02;
|
||||
|
||||
/// One piece of the output: `len` samples read from the source at `src`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Piece {
|
||||
pub src: i64,
|
||||
pub len: i64,
|
||||
}
|
||||
|
||||
/// A remix: pieces played one after the other, crossfaded over `xfade` samples at the joints.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub pieces: Vec<Piece>,
|
||||
pub xfade: i64,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// The plan that plays the source unchanged.
|
||||
pub fn identity(len: i64, xfade: i64) -> Plan {
|
||||
Plan { pieces: vec![Piece { src: 0, len }], xfade }
|
||||
}
|
||||
/// Output length (samples).
|
||||
pub fn len(&self) -> i64 {
|
||||
self.pieces.iter().map(|p| p.len).sum()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
/// Output position of each joint (the start of every piece after the first).
|
||||
pub fn joints(&self) -> Vec<i64> {
|
||||
let mut out = Vec::new();
|
||||
let mut d = 0;
|
||||
for p in &self.pieces[..self.pieces.len().saturating_sub(1)] {
|
||||
d += p.len;
|
||||
out.push(d);
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The source positions each joint cuts at: (end of the outgoing piece, start of the incoming one).
|
||||
pub fn cuts(&self) -> Vec<(i64, i64)> {
|
||||
self.pieces.windows(2).map(|w| (w[0].src + w[0].len, w[1].src)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remix sliders (see the module documentation).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Params {
|
||||
pub segments: f64,
|
||||
pub variations: f64,
|
||||
}
|
||||
|
||||
impl Default for Params {
|
||||
fn default() -> Self {
|
||||
Params { segments: 50.0, variations: 50.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the planner needs to know about the music.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Analysis {
|
||||
pub sample_rate: u32,
|
||||
/// Source length (samples).
|
||||
pub len: i64,
|
||||
/// Beat positions (samples), increasing.
|
||||
pub beats: Vec<i64>,
|
||||
/// Beat period (samples).
|
||||
pub period: f64,
|
||||
/// Tempo (beats per minute).
|
||||
pub bpm: f64,
|
||||
/// Beat self-similarity, `beats.len()²`, row-major; beat `i` is `[beats[i], beats[i + 1])`
|
||||
/// (the last runs to the end).
|
||||
pub sim: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Analysis {
|
||||
pub fn sim(&self, i: usize, j: usize) -> f32 {
|
||||
let m = self.beats.len();
|
||||
self.sim[i * m + j]
|
||||
}
|
||||
|
||||
/// How well a jump from beat boundary `i` (the source is left there) to beat boundary `j`
|
||||
/// (playback continues there) fits: mean similarity of the beats after `i` and after `j`, and
|
||||
/// of the beats before them, over [`CONTEXT`] beats. 0 … 1.
|
||||
pub fn jump_score(&self, i: usize, j: usize) -> f32 {
|
||||
let m = self.beats.len();
|
||||
let (mut s, mut n) = (0f32, 0f32);
|
||||
for k in 0..CONTEXT {
|
||||
if i + k < m && j + k < m {
|
||||
s += self.sim(i + k, j + k);
|
||||
n += 1.0;
|
||||
}
|
||||
if i > k && j > k {
|
||||
s += self.sim(i - 1 - k, j - 1 - k);
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
if n > 0.0 { s / n } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why music cannot be remixed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RemixError {
|
||||
/// Not enough rhythmic content to find a beat (silence, noise, speech).
|
||||
NoBeat,
|
||||
/// The source has fewer beats than a remix needs.
|
||||
TooShort,
|
||||
/// The target is shorter than the intro and outro the remix keeps.
|
||||
TargetTooShort,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RemixError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
RemixError::NoBeat => "no beat found in the audio",
|
||||
RemixError::TooShort => "the music is too short to remix",
|
||||
RemixError::TargetTooShort => "the target duration is too short for this music",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RemixError {}
|
||||
|
||||
// ------------------------------------------------------------------------------------- analysis
|
||||
|
||||
fn hann(n: usize) -> Vec<f32> {
|
||||
(0..n).map(|i| 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / n as f64).cos() as f32).collect()
|
||||
}
|
||||
|
||||
/// Magnitude spectra of `x` with frame `n` and `hop` (frames start at `t·hop`).
|
||||
fn stft_mag(x: &[f32], n: usize, hop: usize) -> Vec<Vec<f32>> {
|
||||
let fft = Fft::new(n);
|
||||
let w = hann(n);
|
||||
let frames = if x.len() >= n { (x.len() - n) / hop + 1 } else { 1 };
|
||||
let mut re = vec![0f32; n];
|
||||
let mut im = vec![0f32; n];
|
||||
let mut out = Vec::with_capacity(frames);
|
||||
for t in 0..frames {
|
||||
let s = t * hop;
|
||||
for i in 0..n {
|
||||
re[i] = x.get(s + i).copied().unwrap_or(0.0) * w[i];
|
||||
im[i] = 0.0;
|
||||
}
|
||||
fft.forward(&mut re, &mut im);
|
||||
out.push((0..n / 2 + 1).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// STFT frame for the onset envelope: the power of two nearest to 46 ms (1024 at 22.05 kHz, 2048
|
||||
/// at 44.1/48 kHz).
|
||||
fn onset_frame(sr: u32) -> usize {
|
||||
let n = (sr as f64 * 0.046).max(256.0);
|
||||
1usize << n.log2().round() as u32
|
||||
}
|
||||
|
||||
/// Below this normalised autocorrelation at the beat period there is no usable pulse.
|
||||
const MIN_PULSE: f64 = 0.1;
|
||||
|
||||
/// Onset strength per hop: half-wave rectified log-magnitude spectral flux, normalised to unit
|
||||
/// standard deviation. Returns (envelope, frame size, hop).
|
||||
pub fn onset_envelope(x: &[f32], sr: u32) -> (Vec<f32>, usize, usize) {
|
||||
let n = onset_frame(sr);
|
||||
let hop = n / 4;
|
||||
let mags = stft_mag(x, n, hop);
|
||||
let mut env = vec![0f32; mags.len()];
|
||||
let lg: Vec<Vec<f32>> = mags.iter().map(|m| m.iter().map(|v| (1.0 + 1000.0 * v).ln()).collect()).collect();
|
||||
for t in 1..lg.len() {
|
||||
env[t] = lg[t].iter().zip(&lg[t - 1]).map(|(a, b)| (a - b).max(0.0)).sum();
|
||||
}
|
||||
let mean = env.iter().sum::<f32>() / env.len().max(1) as f32;
|
||||
let sd = (env.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / env.len().max(1) as f32).sqrt();
|
||||
if sd > 0.0 {
|
||||
env.iter_mut().for_each(|v| *v /= sd);
|
||||
}
|
||||
(env, n, hop)
|
||||
}
|
||||
|
||||
/// Beat period in envelope frames from the autocorrelation (50–200 BPM, prior centred at 120 BPM).
|
||||
fn tempo(env: &[f32], frame_rate: f64) -> Option<f64> {
|
||||
let mean = env.iter().sum::<f32>() / env.len().max(1) as f32;
|
||||
let e: Vec<f32> = env.iter().map(|v| v - mean).collect();
|
||||
let lo = (frame_rate * 60.0 / 200.0).floor().max(2.0) as usize;
|
||||
let hi = ((frame_rate * 60.0 / 50.0).ceil() as usize).min(e.len() / 2);
|
||||
if hi <= lo + 2 {
|
||||
return None;
|
||||
}
|
||||
let ac = |lag: usize| -> f64 { e.iter().zip(&e[lag..]).map(|(a, b)| (*a as f64) * (*b as f64)).sum::<f64>() / (e.len() - lag) as f64 };
|
||||
let raw: Vec<f64> = (lo - 1..=hi + 1).map(ac).collect();
|
||||
let weighted = |k: usize| {
|
||||
let lag = (lo - 1 + k) as f64;
|
||||
let bpm = 60.0 * frame_rate / lag;
|
||||
raw[k] * (-0.5 * ((bpm / 120.0).log2() / 1.0).powi(2)).exp()
|
||||
};
|
||||
let (mut best, mut bk) = (f64::MIN, 0usize);
|
||||
for k in 1..raw.len() - 1 {
|
||||
let w = weighted(k);
|
||||
if w > best {
|
||||
best = w;
|
||||
bk = k;
|
||||
}
|
||||
}
|
||||
if best <= 0.0 || raw[bk] <= MIN_PULSE * ac(0) {
|
||||
return None;
|
||||
}
|
||||
// parabolic refinement on the raw autocorrelation
|
||||
let (a, b, c) = (raw[bk - 1], raw[bk], raw[bk + 1]);
|
||||
let den = a - 2.0 * b + c;
|
||||
let d = if den < 0.0 { (0.5 * (a - c) / den).clamp(-0.5, 0.5) } else { 0.0 };
|
||||
Some((lo - 1 + bk) as f64 + d)
|
||||
}
|
||||
|
||||
/// Dynamic-programming beat tracker: frame indices of beats.
|
||||
fn track_beats(env: &[f32], period: f64) -> Vec<usize> {
|
||||
let n = env.len();
|
||||
let mut score = vec![0f64; n];
|
||||
let mut back = vec![usize::MAX; n];
|
||||
let tight = 100.0;
|
||||
let lo = (period * 0.5).round().max(1.0) as usize;
|
||||
let hi = (period * 2.0).round() as usize;
|
||||
for t in 0..n {
|
||||
let mut best = 0.0f64;
|
||||
let mut arg = usize::MAX;
|
||||
if t >= lo {
|
||||
for prev in t.saturating_sub(hi)..=t - lo {
|
||||
let r = ((t - prev) as f64 / period).ln();
|
||||
let v = score[prev] - tight * r * r;
|
||||
if arg == usize::MAX || v > best {
|
||||
best = v;
|
||||
arg = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
score[t] = env[t] as f64 + if arg == usize::MAX { 0.0 } else { best.max(0.0) };
|
||||
back[t] = if arg != usize::MAX && best > 0.0 { arg } else { usize::MAX };
|
||||
}
|
||||
// strongest end within the last period
|
||||
let tail = n.saturating_sub(period.ceil() as usize + 1);
|
||||
let mut t = (tail..n).max_by(|&a, &b| score[a].total_cmp(&score[b]).then(b.cmp(&a))).unwrap_or(0);
|
||||
let mut beats = vec![t];
|
||||
while back[t] != usize::MAX {
|
||||
t = back[t];
|
||||
beats.push(t);
|
||||
}
|
||||
beats.reverse();
|
||||
beats
|
||||
}
|
||||
|
||||
fn hz_to_mel(f: f64) -> f64 {
|
||||
2595.0 * (1.0 + f / 700.0).log10()
|
||||
}
|
||||
|
||||
fn mel_to_hz(m: f64) -> f64 {
|
||||
700.0 * (10f64.powf(m / 2595.0) - 1.0)
|
||||
}
|
||||
|
||||
/// Per-beat chroma (12) and cepstra (12) from a 4× larger STFT.
|
||||
fn beat_features(x: &[f32], sr: u32, beats: &[i64]) -> (Vec<[f32; 12]>, Vec<[f32; 12]>) {
|
||||
let n = (onset_frame(sr) * 2).max(1024);
|
||||
let hop = n / 2;
|
||||
let mags = stft_mag(x, n, hop);
|
||||
let bins = n / 2 + 1;
|
||||
let hz = |k: usize| k as f64 * sr as f64 / n as f64;
|
||||
// chroma map
|
||||
let pc: Vec<Option<usize>> = (0..bins)
|
||||
.map(|k| {
|
||||
let f = hz(k);
|
||||
(55.0..=5000.0).contains(&f).then(|| (((12.0 * (f / 440.0).log2()).round() as i64 + 9).rem_euclid(12)) as usize)
|
||||
})
|
||||
.collect();
|
||||
// mel filterbank (26 bands, 30 Hz … min(8 kHz, Nyquist))
|
||||
const BANDS: usize = 26;
|
||||
let fmax = (sr as f64 / 2.0).min(8000.0);
|
||||
let (m0, m1) = (hz_to_mel(30.0), hz_to_mel(fmax));
|
||||
let edges: Vec<f64> = (0..BANDS + 2).map(|i| mel_to_hz(m0 + (m1 - m0) * i as f64 / (BANDS + 1) as f64)).collect();
|
||||
let mut frame_chroma = Vec::with_capacity(mags.len());
|
||||
let mut frame_cep = Vec::with_capacity(mags.len());
|
||||
for m in &mags {
|
||||
let mut c = [0f32; 12];
|
||||
for (k, v) in m.iter().enumerate() {
|
||||
if let Some(p) = pc[k] {
|
||||
c[p] += v * v;
|
||||
}
|
||||
}
|
||||
frame_chroma.push(c);
|
||||
let mut band = [0f64; BANDS];
|
||||
for (b, e) in band.iter_mut().enumerate() {
|
||||
let (l, cn, r) = (edges[b], edges[b + 1], edges[b + 2]);
|
||||
for (k, v) in m.iter().enumerate() {
|
||||
let f = hz(k);
|
||||
let w = if f > l && f < cn {
|
||||
(f - l) / (cn - l)
|
||||
} else if f >= cn && f < r {
|
||||
(r - f) / (r - cn)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
*e += w * (*v as f64) * (*v as f64);
|
||||
}
|
||||
}
|
||||
let lb: Vec<f64> = band.iter().map(|e| (e + 1e-10).ln()).collect();
|
||||
let mut cep = [0f32; 12];
|
||||
for (q, c) in cep.iter_mut().enumerate() {
|
||||
let q = q + 1;
|
||||
*c = lb.iter().enumerate().map(|(b, v)| v * (std::f64::consts::PI * q as f64 * (b as f64 + 0.5) / BANDS as f64).cos()).sum::<f64>() as f32;
|
||||
}
|
||||
frame_cep.push(cep);
|
||||
}
|
||||
let mut chroma = Vec::with_capacity(beats.len());
|
||||
let mut cep = Vec::with_capacity(beats.len());
|
||||
let end = x.len() as i64;
|
||||
for (i, &b) in beats.iter().enumerate() {
|
||||
let e = beats.get(i + 1).copied().unwrap_or(end);
|
||||
let (mut c, mut q, mut cnt) = ([0f32; 12], [0f32; 12], 0f32);
|
||||
for (t, (fc, fq)) in frame_chroma.iter().zip(&frame_cep).enumerate() {
|
||||
let centre = (t * hop + n / 2) as i64;
|
||||
if centre >= b && centre < e {
|
||||
for k in 0..12 {
|
||||
c[k] += fc[k];
|
||||
q[k] += fq[k];
|
||||
}
|
||||
cnt += 1.0;
|
||||
}
|
||||
}
|
||||
if cnt > 0.0 {
|
||||
q.iter_mut().for_each(|v| *v /= cnt);
|
||||
}
|
||||
let norm = c.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
c.iter_mut().for_each(|v| *v /= norm);
|
||||
}
|
||||
chroma.push(c);
|
||||
cep.push(q);
|
||||
}
|
||||
// z-score the cepstra across beats
|
||||
let m = cep.len().max(1) as f32;
|
||||
for k in 0..12 {
|
||||
let mean = cep.iter().map(|c| c[k]).sum::<f32>() / m;
|
||||
let sd = (cep.iter().map(|c| (c[k] - mean).powi(2)).sum::<f32>() / m).sqrt().max(1e-6);
|
||||
cep.iter_mut().for_each(|c| c[k] = (c[k] - mean) / sd);
|
||||
}
|
||||
(chroma, cep)
|
||||
}
|
||||
|
||||
fn cos(a: &[f32; 12], b: &[f32; 12]) -> f32 {
|
||||
let d: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let na = a.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
let nb = b.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
if na > 0.0 && nb > 0.0 { d / (na * nb) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// How far [`Analysis::beats`] sit before the attack of each beat (half a crossfade), samples.
|
||||
pub fn beat_lead(sample_rate: u32) -> i64 {
|
||||
(XFADE_S * 0.5 * sample_rate as f64).round() as i64
|
||||
}
|
||||
|
||||
fn local_max(env: &[f32], t: usize, r: usize) -> f32 {
|
||||
env[t.saturating_sub(r)..(t + r + 1).min(env.len())].iter().copied().fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Analyse planar audio (channels are summed to mono).
|
||||
pub fn analyze(channels: &[&[f32]], sample_rate: u32) -> Result<Analysis, RemixError> {
|
||||
let len = channels.iter().map(|c| c.len()).max().unwrap_or(0);
|
||||
let k = 1.0 / channels.len().max(1) as f32;
|
||||
let mono: Vec<f32> = (0..len).map(|i| channels.iter().map(|c| c.get(i).copied().unwrap_or(0.0)).sum::<f32>() * k).collect();
|
||||
let (env, n, hop) = onset_envelope(&mono, sample_rate);
|
||||
let frame_rate = sample_rate as f64 / hop as f64;
|
||||
let period = tempo(&env, frame_rate).ok_or(RemixError::NoBeat)?;
|
||||
let mut frames = track_beats(&env, period);
|
||||
// weak beats at the edges (silence before / after the music) are not beats
|
||||
let mut strengths: Vec<f32> = frames.iter().map(|&t| local_max(&env, t, 2)).collect();
|
||||
strengths.sort_by(f32::total_cmp);
|
||||
let median = strengths.get(strengths.len() / 2).copied().unwrap_or(0.0);
|
||||
while frames.first().is_some_and(|&t| local_max(&env, t, 2) < 0.3 * median) {
|
||||
frames.remove(0);
|
||||
}
|
||||
while frames.last().is_some_and(|&t| local_max(&env, t, 2) < 0.3 * median) {
|
||||
frames.pop();
|
||||
}
|
||||
// …and edge beats off the grid (the tracker's end search can settle on an off-beat)
|
||||
let off_grid = |a: usize, b: usize| ((b - a) as f64 / period - 1.0).abs() > 0.05;
|
||||
while frames.len() > 2 && off_grid(frames[frames.len() - 2], frames[frames.len() - 1]) {
|
||||
frames.pop();
|
||||
}
|
||||
while frames.len() > 2 && off_grid(frames[0], frames[1]) {
|
||||
frames.remove(0);
|
||||
}
|
||||
// the flux of frame t peaks as soon as an attack enters the newer window, at about
|
||||
// t·hop + n − ¾·hop (measured on generated drums); joints sit half a crossfade earlier so a joint's fade is over
|
||||
// when the beat's attack starts
|
||||
let mut beats: Vec<i64> =
|
||||
frames.iter().map(|&t| (t * hop + n) as i64 - 3 * hop as i64 / 4 - beat_lead(sample_rate)).filter(|&b| b > 0 && b < len as i64).collect();
|
||||
beats.dedup();
|
||||
if beats.len() < 2 * MIN_PIECE_BEATS + 2 {
|
||||
return Err(if beats.len() < 4 { RemixError::NoBeat } else { RemixError::TooShort });
|
||||
}
|
||||
let (chroma, cep) = beat_features(&mono, sample_rate, &beats);
|
||||
let m = beats.len();
|
||||
let mut sim = vec![0f32; m * m];
|
||||
for i in 0..m {
|
||||
for j in 0..m {
|
||||
sim[i * m + j] = 0.6 * cos(&chroma[i], &chroma[j]) + 0.4 * (1.0 + cos(&cep[i], &cep[j])) * 0.5;
|
||||
}
|
||||
}
|
||||
let period_s = period * hop as f64;
|
||||
Ok(Analysis { sample_rate, len: len as i64, beats, period: period_s, bpm: 60.0 * sample_rate as f64 / period_s, sim })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- planning
|
||||
|
||||
/// Number of joints the Segments slider asks for.
|
||||
pub fn joints_for(segments: f64) -> usize {
|
||||
1 + (segments.clamp(0.0, 100.0) / 34.0).floor() as usize
|
||||
}
|
||||
|
||||
/// Plan a remix of the analysed music to `target` samples.
|
||||
pub fn plan(a: &Analysis, target: i64, params: Params) -> Result<Plan, RemixError> {
|
||||
let xfade = (XFADE_S * a.sample_rate as f64).round() as i64;
|
||||
let len = a.len;
|
||||
let half = (a.period / 2.0) as i64;
|
||||
if (target - len).abs() <= half {
|
||||
return Ok(Plan::identity(len, xfade));
|
||||
}
|
||||
let b = &a.beats;
|
||||
let m = b.len();
|
||||
let min_piece = (MIN_PIECE_BEATS as f64 * a.period) as i64;
|
||||
if target < 2 * min_piece {
|
||||
return Err(RemixError::TargetTooShort);
|
||||
}
|
||||
let window = 1 + (7.0 * params.variations.clamp(0.0, 100.0) / 100.0).round() as usize;
|
||||
let mut k = joints_for(params.segments).min(((target - len).abs() as f64 / a.period).round().max(1.0) as usize);
|
||||
// pieces may not get shorter than the minimum
|
||||
while k > 1 && target / (k as i64 + 1) < min_piece {
|
||||
k -= 1;
|
||||
}
|
||||
// a backwards jump can span at most the music between the intro and outro pieces
|
||||
if target > len {
|
||||
let max_span = (len - 2 * min_piece).max(a.period as i64);
|
||||
while ((target - len) as f64 / k as f64) > max_span as f64 * 0.9 && (target / (k as i64 + 2)) >= min_piece {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
let nearest = |x: i64| -> usize { b.partition_point(|&v| v < x).min(m - 1) };
|
||||
let closest = |x: i64| -> usize {
|
||||
let i = nearest(x);
|
||||
if i > 0 && (x - b[i - 1]).abs() <= (b[i] - x).abs() { i - 1 } else { i }
|
||||
};
|
||||
let mut pieces = Vec::new();
|
||||
let mut src = 0i64; // start of the current piece in the source
|
||||
let mut out = 0i64; // output length so far
|
||||
for jn in 0..k {
|
||||
let left = (k - jn) as i64; // joints still to place (this one included)
|
||||
let out_left = target - out; // output still to make
|
||||
let piece = out_left / (left + 1); // even split of what is left
|
||||
let src_left = len - src;
|
||||
let span = (src_left - out_left) / left; // source skipped (+) or replayed (−) per joint
|
||||
let ni = closest(src + piece);
|
||||
let last = jn + 1 == k;
|
||||
let mut best: Option<(f32, i64, usize, usize)> = None;
|
||||
let lo_i = ni.saturating_sub(window);
|
||||
for i in lo_i..=(ni + window).min(m - 1) {
|
||||
if b[i] - src < min_piece {
|
||||
continue;
|
||||
}
|
||||
let nj = closest(b[i] + span);
|
||||
for j in nj.saturating_sub(window)..=(nj + window).min(m - 1) {
|
||||
if j == i || len - b[j] < min_piece || (b[j] > b[i]) != (target < len) {
|
||||
continue;
|
||||
}
|
||||
let new_out = out + (b[i] - src);
|
||||
let rest_src = len - b[j];
|
||||
// what the remaining joints still have to absorb
|
||||
let err = (target - new_out) - rest_src;
|
||||
if last {
|
||||
if err.abs() > half.max(1) * 2 {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// remaining joints must still be able to fit pieces of the minimum length
|
||||
let rem_out = target - new_out;
|
||||
if rem_out < (left) * min_piece {
|
||||
continue;
|
||||
}
|
||||
// the joints after this one must still each skip (or replay) a beat or more
|
||||
let rem = rest_src - rem_out;
|
||||
if rem.signum() != (len - target).signum() || (rem.abs() as f64) < (left - 1) as f64 * a.period * 0.75 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let score = a.jump_score(i, j);
|
||||
// final joint: land as close to the target as possible, then by similarity
|
||||
let key_err = if last { err.abs() / half.max(1) } else { 0 };
|
||||
let better = match best {
|
||||
None => true,
|
||||
Some((s, e, bi, bj)) => {
|
||||
let bad = key_err.cmp(&e);
|
||||
bad.is_lt() || (bad.is_eq() && (score > s || (score == s && (i, j) < (bi, bj))))
|
||||
}
|
||||
};
|
||||
if better {
|
||||
best = Some((score, key_err, i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, _, i, j)) = best else {
|
||||
return Err(RemixError::TargetTooShort);
|
||||
};
|
||||
pieces.push(Piece { src, len: b[i] - src });
|
||||
out += b[i] - src;
|
||||
src = b[j];
|
||||
}
|
||||
pieces.push(Piece { src, len: len - src });
|
||||
Ok(Plan { pieces, xfade })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- render
|
||||
|
||||
/// Render output samples `[out0, out0 + n)` of `plan` with `channels` channels. `read(src, len)`
|
||||
/// returns planar source samples `[src, src + len)` (silence outside the source is fine). Each
|
||||
/// joint at output position `J` crossfades over `[J − xfade/2, J + xfade/2)` with equal-power
|
||||
/// gains; the result does not depend on how the output is cut into requests.
|
||||
pub fn render(plan: &Plan, out0: i64, n: usize, channels: usize, read: &mut dyn FnMut(i64, usize) -> Vec<Vec<f32>>) -> Vec<Vec<f32>> {
|
||||
let mut out = vec![vec![0f32; n]; channels];
|
||||
let x = plan.xfade.max(0);
|
||||
let h0 = x / 2;
|
||||
let h1 = x - h0;
|
||||
let end = out0 + n as i64;
|
||||
let last = plan.pieces.len().saturating_sub(1);
|
||||
let mut d = 0i64;
|
||||
for (pi, p) in plan.pieces.iter().enumerate() {
|
||||
let (ds, de) = (d, d + p.len);
|
||||
d = de;
|
||||
let fade_in = pi > 0 && x > 0;
|
||||
let fade_out = pi < last && x > 0;
|
||||
let s = if fade_in { ds - h0 } else { ds };
|
||||
let e = if fade_out { de + h1 } else { de };
|
||||
let (a, z) = (s.max(out0), e.min(end));
|
||||
if z <= a {
|
||||
continue;
|
||||
}
|
||||
let len = (z - a) as usize;
|
||||
let buf = read(p.src + (a - ds), len);
|
||||
for i in 0..len {
|
||||
let o = a + i as i64;
|
||||
let mut g = 1.0f64;
|
||||
if fade_in && o < ds + h1 {
|
||||
let u = (o - (ds - h0)) as f64 + 0.5;
|
||||
g *= (u / x as f64 * std::f64::consts::FRAC_PI_2).sin();
|
||||
}
|
||||
if fade_out && o >= de - h0 {
|
||||
let u = (o - (de - h0)) as f64 + 0.5;
|
||||
g *= (u / x as f64 * std::f64::consts::FRAC_PI_2).cos();
|
||||
}
|
||||
let g = g as f32;
|
||||
let k = (o - out0) as usize;
|
||||
for (c, dst) in out.iter_mut().enumerate() {
|
||||
let src = buf.get(c.min(buf.len().saturating_sub(1))).and_then(|ch| ch.get(i)).copied().unwrap_or(0.0);
|
||||
dst[k] += src * g;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- serialise
|
||||
|
||||
impl Plan {
|
||||
/// Text form: `xfade;src,len;src,len;…` (the unit is the caller's).
|
||||
pub fn to_text(&self) -> String {
|
||||
let mut s = self.xfade.to_string();
|
||||
for p in &self.pieces {
|
||||
s.push_str(&format!(";{},{}", p.src, p.len));
|
||||
}
|
||||
s
|
||||
}
|
||||
pub fn from_text(s: &str) -> Option<Plan> {
|
||||
let mut it = s.split(';');
|
||||
let xfade = it.next()?.trim().parse().ok()?;
|
||||
let mut pieces = Vec::new();
|
||||
for p in it {
|
||||
let (a, b) = p.split_once(',')?;
|
||||
pieces.push(Piece { src: a.trim().parse().ok()?, len: b.trim().parse().ok()? });
|
||||
}
|
||||
(!pieces.is_empty()).then_some(Plan { pieces, xfade })
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated rhythmic test music: drums on every beat, a bass note per beat and a chord pad per
|
||||
/// bar following `sections` of four-bar progressions. Returns stereo planar audio and the true
|
||||
/// beat positions (samples). Used by tests here and in the engine.
|
||||
pub fn test_music(sr: u32, bpm: f64, bars: usize, seed: u32) -> (Vec<Vec<f32>>, Vec<i64>) {
|
||||
let beat = sr as f64 * 60.0 / bpm;
|
||||
let n = (bars as f64 * 4.0 * beat).round() as usize;
|
||||
let mut l = vec![0f32; n];
|
||||
let mut rng = seed.wrapping_mul(747_796_405).wrapping_add(2_891_336_453);
|
||||
let mut noise = move || {
|
||||
rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(rng >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0
|
||||
};
|
||||
// chord roots (semitones from A3 = 220 Hz): section A: C Am F G, B: Dm G Em A
|
||||
let a_sec = [3, 0, 8, 10];
|
||||
let b_sec = [5, 10, 7, 0];
|
||||
let form = [a_sec, a_sec, b_sec, a_sec, b_sec, a_sec];
|
||||
let minor = |r: i32| matches!(r, 0 | 5 | 7);
|
||||
let tau = std::f64::consts::TAU;
|
||||
let mut beats = Vec::new();
|
||||
for bar in 0..bars {
|
||||
let root = form[(bar / 4) % form.len()][bar % 4];
|
||||
let third = if minor(root) { 3 } else { 4 };
|
||||
let notes = [root, root + third, root + 7];
|
||||
let b0 = (bar as f64 * 4.0 * beat).round() as usize;
|
||||
let b1 = (((bar + 1) as f64) * 4.0 * beat).round() as usize;
|
||||
for (i, s) in l.iter_mut().enumerate().take(b1.min(n)).skip(b0) {
|
||||
let t = (i - b0) as f64 / sr as f64;
|
||||
let env = (1.0 - (-t * 30.0).exp()) * 0.9f64.powf(t);
|
||||
let mut v = 0.0;
|
||||
for nt in notes {
|
||||
let f = 220.0 * 2f64.powf(nt as f64 / 12.0);
|
||||
v += (tau * f * i as f64 / sr as f64).sin() + 0.3 * (tau * 2.0 * f * i as f64 / sr as f64).sin();
|
||||
}
|
||||
*s += (0.06 * env * v) as f32;
|
||||
}
|
||||
for q in 0..4 {
|
||||
let start = ((bar * 4 + q) as f64 * beat).round() as usize;
|
||||
beats.push(start as i64);
|
||||
let bass_f = 110.0 * 2f64.powf(root as f64 / 12.0) / if q % 2 == 0 { 1.0 } else { 2.0f64.powf(-7.0 / 12.0) };
|
||||
let dur = (beat * 0.9) as usize;
|
||||
for k in 0..dur.min(n.saturating_sub(start)) {
|
||||
let t = k as f64 / sr as f64;
|
||||
let i = start + k;
|
||||
// kick (1 and 3) or snare (2 and 4)
|
||||
if q % 2 == 0 {
|
||||
let f = 50.0 + 90.0 * (-t * 25.0).exp();
|
||||
l[i] += (0.8 * (-t * 9.0).exp() * (tau * f * t).sin()) as f32;
|
||||
} else if t < 0.2 {
|
||||
l[i] += 0.35 * (-t as f32 * 22.0).exp() * noise();
|
||||
}
|
||||
// bass
|
||||
l[i] += (0.15 * (-t * 3.0).exp() * (tau * bass_f * t).sin()) as f32;
|
||||
}
|
||||
// hats on 8ths
|
||||
for h in 0..2 {
|
||||
let hs = start + (h as f64 * beat / 2.0) as usize;
|
||||
for k in 0..(sr as usize / 25).min(n.saturating_sub(hs)) {
|
||||
let t = k as f32 / sr as f32;
|
||||
l[hs + k] += 0.08 * (-t * 120.0).exp() * noise();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = l.clone();
|
||||
(vec![l, r], beats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SR: u32 = 22_050;
|
||||
|
||||
fn music(bpm: f64, bars: usize) -> (Vec<Vec<f32>>, Vec<i64>, Analysis) {
|
||||
let (m, truth) = test_music(SR, bpm, bars, 7);
|
||||
let refs: Vec<&[f32]> = m.iter().map(Vec::as_slice).collect();
|
||||
let a = analyze(&refs, SR).expect("analysis");
|
||||
(m, truth, a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_tempo_and_beats() {
|
||||
for (bpm, sr) in [(100.0, SR), (120.0, SR), (128.0, SR), (120.0, 48_000), (90.0, 44_100)] {
|
||||
let (m, truth) = test_music(sr, bpm, 16, 7);
|
||||
let refs: Vec<&[f32]> = m.iter().map(Vec::as_slice).collect();
|
||||
let a = analyze(&refs, sr).expect("analysis");
|
||||
assert!((a.bpm - bpm).abs() < 1.0, "bpm {} vs {bpm}", a.bpm);
|
||||
// every detected beat (plus the lead) is within 10 ms of a true beat; at most 2 true beats missed
|
||||
let tol = (0.010 * sr as f64) as i64;
|
||||
let mut worst = 0;
|
||||
for b in a.beats.iter().map(|b| b + beat_lead(sr)) {
|
||||
let d = truth.iter().map(|t| (t - b).abs()).min().unwrap();
|
||||
if d > tol {
|
||||
eprintln!("beat {b} off by {d} (first true {}, last true {}, len {})", truth[0], truth[truth.len() - 1], a.len);
|
||||
}
|
||||
worst = worst.max(d);
|
||||
}
|
||||
assert!(worst <= tol, "worst beat error {} samples ({bpm} BPM)", worst);
|
||||
assert!(a.beats.len() + 2 >= truth.len(), "{} beats of {}", a.beats.len(), truth.len());
|
||||
eprintln!("{bpm} BPM at {sr} Hz: detected {:.2}, {} beats, worst error {:.1} ms", a.bpm, a.beats.len(), worst as f64 * 1000.0 / sr as f64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_prefers_same_section() {
|
||||
let (_, _, a) = music(120.0, 24);
|
||||
// bar 0 (A section, C chord) is closer to bar 4 (A, C) than to bar 8 (B, Dm)
|
||||
let first = |bar: usize| a.beats.iter().position(|&b| b >= (bar as f64 * 4.0 * a.period) as i64 - (a.period / 4.0) as i64).unwrap();
|
||||
let (i, ja, jb) = (first(1), first(5), first(9));
|
||||
assert!(a.jump_score(i, ja) > a.jump_score(i, jb), "{} vs {}", a.jump_score(i, ja), a.jump_score(i, jb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_hit_targets_at_beat_boundaries() {
|
||||
let (_, _, a) = music(120.0, 24); // 48 s
|
||||
let beat = a.period as i64;
|
||||
for secs in [20.0, 30.0, 40.0, 47.0, 60.0, 75.0, 100.0] {
|
||||
for segments in [0.0, 50.0, 100.0] {
|
||||
for variations in [0.0, 50.0, 100.0] {
|
||||
let target = (secs * SR as f64) as i64;
|
||||
let p = plan(&a, target, Params { segments, variations }).expect("plan");
|
||||
let err = (p.len() - target).abs();
|
||||
assert!(err <= beat, "{secs} s seg {segments} var {variations}: error {err} > beat {beat}");
|
||||
assert_eq!(p.pieces[0].src, 0, "starts at the intro");
|
||||
let lastp = p.pieces.last().unwrap();
|
||||
assert_eq!(lastp.src + lastp.len, a.len, "ends at the outro");
|
||||
for (out_end, in_start) in p.cuts() {
|
||||
assert!(a.beats.contains(&out_end), "cut out at {out_end} is not a beat");
|
||||
assert!(a.beats.contains(&in_start), "cut in at {in_start} is not a beat");
|
||||
}
|
||||
for pc in &p.pieces {
|
||||
assert!(pc.len >= (MIN_PIECE_BEATS as f64 * a.period) as i64 - 2, "piece too short: {pc:?}");
|
||||
}
|
||||
if segments == 50.0 && variations == 50.0 {
|
||||
eprintln!("{secs} s: {} pieces, error {:.1} ms", p.pieces.len(), err as f64 * 1000.0 / SR as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_and_parameters_matter() {
|
||||
let (_, _, a) = music(120.0, 24);
|
||||
let (_, _, a2) = music(120.0, 24);
|
||||
assert_eq!(a, a2);
|
||||
let t = 30 * SR as i64;
|
||||
let p = plan(&a, t, Params::default()).unwrap();
|
||||
assert_eq!(p, plan(&a2, t, Params::default()).unwrap());
|
||||
let few = plan(&a, t, Params { segments: 0.0, variations: 50.0 }).unwrap();
|
||||
let many = plan(&a, t, Params { segments: 100.0, variations: 50.0 }).unwrap();
|
||||
assert!(few.pieces.len() < many.pieces.len(), "{} vs {}", few.pieces.len(), many.pieces.len());
|
||||
// identity when the target is the source length
|
||||
assert_eq!(plan(&a, a.len, Params::default()).unwrap().pieces.len(), 1);
|
||||
assert_eq!(plan(&a, SR as i64, Params::default()), Err(RemixError::TargetTooShort));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_and_noise_have_no_beat() {
|
||||
let z = vec![0f32; SR as usize * 10];
|
||||
assert!(analyze(&[&z], SR).is_err());
|
||||
let tone: Vec<f32> = (0..SR as usize * 20).map(|i| (i as f32 * 0.05).sin() * 0.3).collect();
|
||||
assert!(analyze(&[&tone], SR).is_err());
|
||||
let mut x = 12345u32;
|
||||
let noise: Vec<f32> = (0..SR as usize * 20)
|
||||
.map(|_| {
|
||||
x = x.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(x >> 8) as f32 / (1u32 << 24) as f32 - 0.5
|
||||
})
|
||||
.collect();
|
||||
assert!(analyze(&[&noise], SR).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_crossfades_without_clicks_and_ignores_block_size() {
|
||||
let (m, _, a) = music(120.0, 24);
|
||||
let p = plan(&a, 30 * SR as i64, Params::default()).unwrap();
|
||||
let mut read = |s: i64, n: usize| -> Vec<Vec<f32>> {
|
||||
m.iter().map(|c| (0..n as i64).map(|k| c.get((s + k).max(0) as usize).copied().unwrap_or(0.0)).collect()).collect()
|
||||
};
|
||||
let whole = render(&p, 0, p.len() as usize, 2, &mut read);
|
||||
// block-size independence
|
||||
let mut cut = vec![Vec::new(), Vec::new()];
|
||||
let mut pos = 0i64;
|
||||
let mut step = 1usize;
|
||||
while pos < p.len() {
|
||||
let n = step.min((p.len() - pos) as usize);
|
||||
let b = render(&p, pos, n, 2, &mut read);
|
||||
for c in 0..2 {
|
||||
cut[c].extend_from_slice(&b[c]);
|
||||
}
|
||||
pos += n as i64;
|
||||
step = step * 7 % 4093 + 1;
|
||||
}
|
||||
assert_eq!(whole, cut);
|
||||
// outside crossfades the output equals the source pieces exactly
|
||||
let mut d = 0i64;
|
||||
for pc in &p.pieces {
|
||||
for k in (p.xfade..pc.len - p.xfade).step_by(97) {
|
||||
assert_eq!(whole[0][(d + k) as usize], m[0][(pc.src + k) as usize]);
|
||||
}
|
||||
d += pc.len;
|
||||
}
|
||||
// no clicks: the largest sample-to-sample step at a joint is no larger than in the music
|
||||
let max_step = |x: &[f32]| x.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0f32, f32::max);
|
||||
let music_step = max_step(&m[0]);
|
||||
for j in p.joints() {
|
||||
let s = (j - p.xfade) as usize;
|
||||
let e = (j + p.xfade) as usize;
|
||||
assert!(max_step(&whole[0][s..e]) <= music_step * 1.05, "click at joint {j}");
|
||||
}
|
||||
// equal-power gains: in the fade, gain² of both sides sums to 1
|
||||
let plan2 = Plan { pieces: vec![Piece { src: 0, len: 100 }, Piece { src: 0, len: 100 }], xfade: 20 };
|
||||
let mut ones = |_s: i64, n: usize| vec![vec![1.0f32; n]];
|
||||
let mut first = |s: i64, n: usize| vec![(0..n as i64).map(|k| if s + k < 100 + 10 && s + k >= 0 { 1.0 } else { 0.0 }).collect()];
|
||||
let _ = render(&plan2, 0, 200, 1, &mut ones);
|
||||
let g = render(&Plan { pieces: vec![Piece { src: 0, len: 100 }, Piece { src: 1000, len: 100 }], xfade: 20 }, 85, 30, 1, &mut first);
|
||||
for (i, v) in g[0].iter().enumerate() {
|
||||
let o = 85 + i as i64;
|
||||
if (90..110).contains(&o) {
|
||||
let u = (o - 90) as f64 + 0.5;
|
||||
let exp = (u / 20.0 * std::f64::consts::FRAC_PI_2).cos() as f32;
|
||||
assert!((v - exp).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_roundtrip() {
|
||||
let p = Plan { pieces: vec![Piece { src: 0, len: 10 }, Piece { src: 50, len: 7 }], xfade: 3 };
|
||||
assert_eq!(Plan::from_text(&p.to_text()), Some(p));
|
||||
assert_eq!(Plan::from_text("x"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -151,6 +151,8 @@ pub struct Preferences {
|
|||
pub trim: TrimPrefs,
|
||||
pub essential_sound: EssentialSoundPrefs,
|
||||
pub guides: GuidePrefs,
|
||||
/// Voice-Over Record Settings (timeline track header ▸ right-click the microphone).
|
||||
pub voice_over: crate::voiceover::VoiceOverPrefs,
|
||||
}
|
||||
|
||||
impl Default for Preferences {
|
||||
|
|
@ -175,6 +177,7 @@ impl Default for Preferences {
|
|||
trim: Default::default(),
|
||||
essential_sound: Default::default(),
|
||||
guides: Default::default(),
|
||||
voice_over: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -493,7 +493,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Sequence…",
|
||||
["File", "New"],
|
||||
Some("Cmd+N"),
|
||||
r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"fromItem":itemId?}"#,
|
||||
r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?}"#,
|
||||
always,
|
||||
|s, p| {
|
||||
let mut st = SequenceSettings::default();
|
||||
|
|
@ -512,6 +512,17 @@ fn build() -> Vec<CommandSpec> {
|
|||
if let Some(sr) = u64_p(p, "sampleRate") {
|
||||
st.sample_rate = sr as u32;
|
||||
}
|
||||
if let Some(m) = str_p(p, "mix") {
|
||||
st.audio_master =
|
||||
crate::mixer::channels_from(m).ok_or_else(|| bad("file.newSequence", format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?;
|
||||
}
|
||||
let track_type = match str_p(p, "trackType") {
|
||||
Some(t) => Some(
|
||||
crate::mixer::channels_from(t)
|
||||
.ok_or_else(|| bad("file.newSequence", format!("unknown track type `{t}` (Standard, Mono, 5.1, Adaptive)")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
st.preset = format!("{}x{} {}", st.width, st.height, st.frame_rate.label());
|
||||
let nv = u64_p(p, "video").unwrap_or(3) as usize;
|
||||
let na = u64_p(p, "audio").unwrap_or(3) as usize;
|
||||
|
|
@ -523,6 +534,9 @@ fn build() -> Vec<CommandSpec> {
|
|||
if let Some(it) = pr.item_mut(id) {
|
||||
it.label = seq_label;
|
||||
}
|
||||
if let (Some(c), Some(q)) = (track_type, pr.sequence_mut(id)) {
|
||||
q.audio_tracks.iter_mut().for_each(|t| t.channels = c);
|
||||
}
|
||||
st2.active_sequence = Some(id);
|
||||
if !st2.open_sequences.contains(&id) {
|
||||
st2.open_sequences.push(id);
|
||||
|
|
@ -1216,27 +1230,47 @@ fn build() -> Vec<CommandSpec> {
|
|||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("sequence.settings", "Sequence Settings…", ["Sequence"], None, r#"{"width":u32?,"height":u32?,"fps":f64?,"name":str?}"#, has_seq, |s, p| {
|
||||
let id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let p = p.clone();
|
||||
s.edit("Sequence Settings", |pr, _| {
|
||||
if let Some(n) = str_p(&p, "name") {
|
||||
pr.item_mut(id).expect("seq").name = n.to_string();
|
||||
}
|
||||
let q = pr.sequence_mut(id).ok_or(EngineError::NoSequence)?;
|
||||
if let Some(w) = u64_p(&p, "width") {
|
||||
q.settings.width = w as u32;
|
||||
}
|
||||
if let Some(h) = u64_p(&p, "height") {
|
||||
q.settings.height = h as u32;
|
||||
}
|
||||
if let Some(f) = f64_p(&p, "fps") {
|
||||
q.settings.frame_rate = FrameRate::from_f64(f);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!(
|
||||
"sequence.settings",
|
||||
"Sequence Settings…",
|
||||
["Sequence"],
|
||||
None,
|
||||
r#"{"width":u32?,"height":u32?,"fps":f64?,"name":str?,"sampleRate":u32?,"mix":"Stereo|Mono|5.1|Adaptive"?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let mix = match str_p(p, "mix") {
|
||||
Some(m) => Some(
|
||||
crate::mixer::channels_from(m).ok_or_else(|| bad("sequence.settings", format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let p = p.clone();
|
||||
s.edit("Sequence Settings", |pr, _| {
|
||||
if let Some(n) = str_p(&p, "name") {
|
||||
pr.item_mut(id).expect("seq").name = n.to_string();
|
||||
}
|
||||
let q = pr.sequence_mut(id).ok_or(EngineError::NoSequence)?;
|
||||
if let Some(w) = u64_p(&p, "width") {
|
||||
q.settings.width = w as u32;
|
||||
}
|
||||
if let Some(h) = u64_p(&p, "height") {
|
||||
q.settings.height = h as u32;
|
||||
}
|
||||
if let Some(f) = f64_p(&p, "fps") {
|
||||
q.settings.frame_rate = FrameRate::from_f64(f);
|
||||
}
|
||||
if let Some(sr) = u64_p(&p, "sampleRate") {
|
||||
q.settings.sample_rate = sr as u32;
|
||||
}
|
||||
if let Some(m) = mix {
|
||||
q.settings.audio_master = m;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
cmd!("sequence.renderEffectsInToOut", "Render Effects In to Out", ["Sequence"], Some("Enter"), r#"{"wait":bool=false}"#, has_seq, |s, p| {
|
||||
crate::previews::render(s, crate::previews::RenderMode::EffectsInToOut, p)
|
||||
}),
|
||||
|
|
@ -2133,6 +2167,8 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::transcript::commands());
|
||||
v.extend(crate::panels::commands());
|
||||
v.extend(crate::scopes::commands());
|
||||
v.extend(crate::remix::commands());
|
||||
v.extend(crate::voiceover::commands());
|
||||
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
|
||||
// and their menu order
|
||||
crate::clip_ops::apply_layout(&mut v);
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ pub mod project_manager;
|
|||
pub mod project_tools;
|
||||
pub mod proxies;
|
||||
pub mod relink;
|
||||
pub mod remix;
|
||||
pub mod scene_detect;
|
||||
pub mod scopes;
|
||||
pub mod sequence_extras;
|
||||
|
|
@ -40,6 +41,7 @@ pub mod shortcuts;
|
|||
pub mod sync;
|
||||
pub mod transcript;
|
||||
pub mod trim;
|
||||
pub mod voiceover;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -218,6 +220,10 @@ pub struct EditorState {
|
|||
/// audio clips too.
|
||||
#[serde(default)]
|
||||
pub multicam_audio_follows_video: bool,
|
||||
/// Audio Clip Mixer automation mode per audio track (track id → mode; missing = Read):
|
||||
/// Latch / Touch / Write record the clip Volume and Panner moves as clip keyframes.
|
||||
#[serde(default)]
|
||||
pub clip_mixer_modes: std::collections::BTreeMap<u64, filmcraft_project::AutomationMode>,
|
||||
/// Sequence ▸ Selection Follows Playhead: moving the playhead selects the clips under it on
|
||||
/// targeted tracks.
|
||||
#[serde(default)]
|
||||
|
|
@ -279,6 +285,8 @@ pub struct Session {
|
|||
pub mixrec: mixer::Recorder,
|
||||
/// Multi-camera live switching pass in progress.
|
||||
pub mcrec: multicam::Recorder,
|
||||
/// Voice-over recording: the input device and the take in progress.
|
||||
pub voiceover: voiceover::VoiceOver,
|
||||
/// Dynamic (J/K/L) trimming and trim-mode loop playback in progress.
|
||||
pub trim_play: trim::TrimPlayback,
|
||||
/// Keyboard shortcuts (active bindings, presets; `shortcuts.*` commands).
|
||||
|
|
@ -376,6 +384,7 @@ impl Session {
|
|||
previews: Arc::new(previews::PreviewStore::temp()),
|
||||
mixrec: Default::default(),
|
||||
mcrec: Default::default(),
|
||||
voiceover: Default::default(),
|
||||
trim_play: Default::default(),
|
||||
shortcuts: shortcuts::Shortcuts::new(),
|
||||
offline: Default::default(),
|
||||
|
|
@ -832,6 +841,8 @@ mod proxies_tests;
|
|||
#[cfg(test)]
|
||||
mod relink_tests;
|
||||
#[cfg(test)]
|
||||
mod remix_tests;
|
||||
#[cfg(test)]
|
||||
mod scopes_tests;
|
||||
#[cfg(test)]
|
||||
mod sequence_tools_tests;
|
||||
|
|
@ -847,3 +858,5 @@ mod transcript_tests;
|
|||
mod trim_tests;
|
||||
#[cfg(test)]
|
||||
mod vfx_tests;
|
||||
#[cfg(test)]
|
||||
mod voiceover_tests;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@
|
|||
//! | Write | every control from the start of playback | keeps writing the last value until playback stops |
|
||||
//!
|
||||
//! With "Switch to Touch after Write" (on by default) a Write strip changes to Touch after the
|
||||
//! pass. Without a recording pass, `mixer.touch` holds the control live and `mixer.release` commits
|
||||
//! pass.
|
||||
//!
|
||||
//! **Audio Clip Mixer.** Each audio track also has a Clip Mixer mode (`clipMixer.setMode`, session
|
||||
//! state, Read by default). Its fader and pan knob are the lanes `clip.volume` / `clip.pan` of the
|
||||
//! track (`clipMixer.touch` / `clipMixer.release`); they are heard live (the clip under the
|
||||
//! playhead plays the held value) and recorded with the same Latch / Touch / Write rules, but the
|
||||
//! pass writes **clip keyframes**: the Volume `level` and Panner `balance` of every clip the
|
||||
//! gesture covers, in media time, thinned like track automation, with boundary keyframes that keep
|
||||
//! each clip's values outside the recorded range. Without a recording pass, `mixer.touch` holds the control live and `mixer.release` commits
|
||||
//! it once (a static value, or a keyframe at the playhead when the lane is automated).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -32,6 +40,8 @@ use filmcraft_time::{TICKS_PER_SECOND, Tick};
|
|||
|
||||
use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
use filmcraft_project::mixer::{LANE_PAN51_X, LANE_PAN51_Y, PAN51_LANES};
|
||||
use filmcraft_render::audio::{CLIP_LANE_PAN, CLIP_LANE_VOLUME};
|
||||
|
||||
type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||||
|
|
@ -103,9 +113,93 @@ impl Recorder {
|
|||
}
|
||||
}
|
||||
|
||||
/// Lanes a Write pass records on every strip.
|
||||
fn write_lanes(strip: TrackId) -> &'static [&'static str] {
|
||||
if strip == MASTER_STRIP { &[LANE_VOLUME] } else { &[LANE_VOLUME, LANE_PAN, LANE_MUTE] }
|
||||
/// Lanes a Write pass records on a strip (the 5.1 puck instead of pan when it feeds a 5.1 bus).
|
||||
fn write_lanes(seq: &Sequence, strip: TrackId) -> &'static [&'static str] {
|
||||
if strip == MASTER_STRIP {
|
||||
&[LANE_VOLUME]
|
||||
} else if seq.pans_51(strip) {
|
||||
&[LANE_VOLUME, LANE_PAN51_X, LANE_PAN51_Y, LANE_MUTE]
|
||||
} else {
|
||||
&[LANE_VOLUME, LANE_PAN, LANE_MUTE]
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- clip lanes
|
||||
|
||||
/// Whether `lane` is an Audio Clip Mixer lane (`clip.volume`, `clip.pan`).
|
||||
pub fn is_clip_lane(lane: &str) -> bool {
|
||||
lane == CLIP_LANE_VOLUME || lane == CLIP_LANE_PAN
|
||||
}
|
||||
|
||||
/// The clip effect parameter behind a Clip Mixer lane.
|
||||
fn clip_target(lane: &str) -> (&'static str, &'static str) {
|
||||
if lane == CLIP_LANE_PAN { ("panner", "balance") } else { ("volume", "level") }
|
||||
}
|
||||
|
||||
/// The Clip Mixer mode of an audio track (Read unless set).
|
||||
pub fn clip_mode(s: &Session, track: TrackId) -> AutomationMode {
|
||||
s.state.clip_mixer_modes.get(&track.0).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The clip lane value at `t`: the clip under `t` on the track (its keyframes or static value).
|
||||
fn clip_underlying(tr: &Track, lane: &str, t: Tick) -> f64 {
|
||||
let (eff, par) = clip_target(lane);
|
||||
tr.item_at(t).and_then(|c| c.effect(eff).map(|e| e.f64_at(par, c.source_time_at(t)))).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn lane_underlying(tr: &Track, lane: &str, t: Tick) -> f64 {
|
||||
if is_clip_lane(lane) { clip_underlying(tr, lane, t) } else { underlying(tr, lane, t) }
|
||||
}
|
||||
|
||||
/// Parse a Clip Mixer lane from `lane` / `param` (`volume`/`level`, `pan`/`balance`).
|
||||
fn clip_lane_p(p: &Value) -> String {
|
||||
match str_p(p, "lane").or_else(|| str_p(p, "param")).unwrap_or("volume") {
|
||||
"pan" | "balance" | "clip.pan" => CLIP_LANE_PAN.to_string(),
|
||||
_ => CLIP_LANE_VOLUME.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write recorded clip-lane points (sequence time) as keyframes into every clip of `tr` they cover.
|
||||
fn write_clip_points(tr: &mut Track, lane: &str, pts: &[(Tick, f64)], sr: i64) -> usize {
|
||||
let (eff, par) = clip_target(lane);
|
||||
let (Some(t0), Some(t1)) = (pts.first().map(|p| p.0), pts.last().map(|p| p.0)) else { return 0 };
|
||||
let mut written = 0;
|
||||
for c in tr.items.iter_mut().filter(|c| c.end() > t0 && c.start <= t1) {
|
||||
let c0 = t0.max(c.start);
|
||||
let c1 = t1.min(c.end() - Tick(1));
|
||||
if c1 < c0 {
|
||||
continue;
|
||||
}
|
||||
// points inside the clip, plus the interpolated value at its edges of the range
|
||||
let at = |t: Tick| -> f64 {
|
||||
let i = pts.partition_point(|p| p.0 <= t);
|
||||
match (i.checked_sub(1).map(|j| pts[j]), pts.get(i)) {
|
||||
(Some(a), Some(b)) if b.0 > a.0 => a.1 + (b.1 - a.1) * ((t - a.0).0 as f64 / (b.0 - a.0).0 as f64),
|
||||
(Some(a), _) => a.1,
|
||||
(None, Some(b)) => b.1,
|
||||
(None, None) => 0.0,
|
||||
}
|
||||
};
|
||||
let mut local: Vec<(Tick, f64)> = vec![(c0, at(c0))];
|
||||
local.extend(pts.iter().copied().filter(|p| p.0 > c0 && p.0 < c1));
|
||||
if c1 > c0 {
|
||||
local.push((c1, at(c1)));
|
||||
}
|
||||
let mt: Vec<(Tick, f64)> = local.iter().map(|&(t, v)| (c.source_time_at(t), v)).collect();
|
||||
let (m0, m1) = (mt[0].0.min(mt[mt.len() - 1].0), mt[0].0.max(mt[mt.len() - 1].0));
|
||||
let mut mt = mt;
|
||||
mt.sort_by_key(|p| p.0);
|
||||
let eps = Tick::from_units(1, sr).max(Tick(1));
|
||||
let starts_inside = c0 > c.start;
|
||||
let ends_inside = c1 < c.end() - Tick(1);
|
||||
let Some(e) = c.effect_mut(eff) else { continue };
|
||||
let Some(param) = e.param_mut(par) else { continue };
|
||||
let before = starts_inside.then(|| param.f64_at(m0 - eps));
|
||||
let after = ends_inside.then(|| param.f64_at(m1 + eps));
|
||||
write_lane(param, m0, m1, &mt, before, after, eps, false);
|
||||
written += mt.len();
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// The lane value as automation plays it (no live overrides).
|
||||
|
|
@ -122,6 +216,12 @@ fn underlying(tr: &Track, lane: &str, t: Tick) -> f64 {
|
|||
}
|
||||
|
||||
fn clamp_lane(lane: &str, v: f64) -> f64 {
|
||||
if lane == CLIP_LANE_VOLUME {
|
||||
return v.clamp(FADER_MIN_DB, FADER_MAX_DB);
|
||||
}
|
||||
if lane == CLIP_LANE_PAN {
|
||||
return v.clamp(-100.0, 100.0);
|
||||
}
|
||||
match lane_info(lane) {
|
||||
Some(i) if i.hold => {
|
||||
if v >= 0.5 {
|
||||
|
|
@ -149,13 +249,24 @@ pub fn record_start(s: &mut Session, t: Tick) -> Result<Value> {
|
|||
if tr.mixer.mode != AutomationMode::Write {
|
||||
continue;
|
||||
}
|
||||
for lane in write_lanes(id) {
|
||||
for lane in write_lanes(seq, id) {
|
||||
let v = underlying(&tr, lane, t);
|
||||
let mut g = Gesture::new(id, lane, AutomationMode::Write, t, v);
|
||||
g.touching = false;
|
||||
gestures.push(g);
|
||||
}
|
||||
}
|
||||
// Audio Clip Mixer tracks in Write mode
|
||||
for tr in &seq.audio_tracks {
|
||||
if clip_mode(s, tr.id) != AutomationMode::Write {
|
||||
continue;
|
||||
}
|
||||
for lane in [CLIP_LANE_VOLUME, CLIP_LANE_PAN] {
|
||||
let mut g = Gesture::new(tr.id, lane, AutomationMode::Write, t, clip_underlying(tr, lane, t));
|
||||
g.touching = false;
|
||||
gestures.push(g);
|
||||
}
|
||||
}
|
||||
s.previews.live.clear_all();
|
||||
for g in &gestures {
|
||||
s.previews.live.hold(g.strip, &g.lane, g.value());
|
||||
|
|
@ -169,7 +280,8 @@ pub fn record_start(s: &mut Session, t: Tick) -> Result<Value> {
|
|||
pub fn touch(s: &mut Session, strip: TrackId, lane: &str, v: f64, t: Tick) -> Result<Value> {
|
||||
let v = clamp_lane(lane, v);
|
||||
let seq_id = s.mixrec.seq.or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
|
||||
let mode = seq_of(s, seq_id)?.strip(strip).ok_or_else(|| bad("mixer.touch", "no such strip"))?.mixer.mode;
|
||||
let strip_mode = seq_of(s, seq_id)?.strip(strip).ok_or_else(|| bad("mixer.touch", "no such strip"))?.mixer.mode;
|
||||
let mode = if is_clip_lane(lane) { clip_mode(s, strip) } else { strip_mode };
|
||||
s.previews.live.hold(strip, lane, v);
|
||||
if !s.mixrec.active() || !mode.writes() {
|
||||
return Ok(json!({"recording": false, "value": v}));
|
||||
|
|
@ -214,6 +326,10 @@ pub fn release(s: &mut Session, strip: TrackId, lane: &str, t: Tick) -> Result<V
|
|||
let held = s.previews.live.get(strip, lane);
|
||||
s.previews.live.clear(strip, lane);
|
||||
match held {
|
||||
Some(o) if is_clip_lane(lane) => {
|
||||
set_clip_value(s, strip, lane, o.value, t)?;
|
||||
Ok(json!({"recording": false, "value": o.value}))
|
||||
}
|
||||
Some(o) => {
|
||||
set_value(s, strip, lane, o.value, t)?;
|
||||
Ok(json!({"recording": false, "value": o.value}))
|
||||
|
|
@ -243,7 +359,8 @@ pub fn record_stop(s: &mut Session, t_stop: Tick) -> Result<Value> {
|
|||
continue;
|
||||
}
|
||||
let Some(tr) = seq.strip(g.strip).map(|c| c.into_owned()) else { continue };
|
||||
let hold = lane_info(&g.lane).is_some_and(|i| i.hold);
|
||||
let clip_lane = is_clip_lane(&g.lane);
|
||||
let hold = !clip_lane && lane_info(&g.lane).is_some_and(|i| i.hold);
|
||||
let held = g.value();
|
||||
let end = match g.mode {
|
||||
AutomationMode::Touch => g.released.unwrap_or(t_stop).min(t_stop.max(g.points[0].0)),
|
||||
|
|
@ -254,18 +371,25 @@ pub fn record_stop(s: &mut Session, t_stop: Tick) -> Result<Value> {
|
|||
let mut pts = g.points.clone();
|
||||
if g.mode == AutomationMode::Touch && automatch.0 > 0 && !hold {
|
||||
let back = end + automatch;
|
||||
pts.push((back, underlying(&tr, &g.lane, back)));
|
||||
pts.push((back, lane_underlying(&tr, &g.lane, back)));
|
||||
}
|
||||
let t0 = pts[0].0;
|
||||
let t1 = pts.last().map(|p| p.0).unwrap_or(t0);
|
||||
let tol = if hold {
|
||||
0.0
|
||||
} else if prefs.linear_keyframe_thinning {
|
||||
if g.lane == LANE_PAN { 0.25 } else { 0.05 }
|
||||
if g.lane == LANE_PAN || g.lane == CLIP_LANE_PAN || PAN51_LANES.contains(&g.lane.as_str()) { 0.25 } else { 0.05 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let thinned = thin_points(&pts, tol, min_interval, hold);
|
||||
if clip_lane {
|
||||
let lane = g.lane.clone();
|
||||
let n = seq.mix_track_mut(g.strip).map(|tr| write_clip_points(tr, &lane, &thinned, rate)).unwrap_or(0);
|
||||
keyframes += n;
|
||||
written.push(json!({"strip": g.strip.0, "lane": lane, "from": t0.0, "to": t1.0, "keyframes": n, "points": pts.len(), "clips": true}));
|
||||
continue;
|
||||
}
|
||||
let before = (t0.0 > 0).then(|| underlying(&tr, &g.lane, t0 - eps));
|
||||
let after = tr.lane(&g.lane).filter(|p| p.keyframes.iter().any(|k| k.time > t1)).map(|_| underlying(&tr, &g.lane, t1 + eps));
|
||||
keyframes += thinned.len();
|
||||
|
|
@ -294,6 +418,34 @@ pub fn record_stop(s: &mut Session, t_stop: Tick) -> Result<Value> {
|
|||
Ok(json!({"recording": false, "lanes": written.len(), "keyframes": keyframes, "written": written}))
|
||||
}
|
||||
|
||||
/// Commit a Clip Mixer value outside a pass: the clip under `t` on the track gets the value (a
|
||||
/// keyframe at `t` when the parameter is animated, else its static value).
|
||||
pub fn set_clip_value(s: &mut Session, track: TrackId, lane: &str, v: f64, t: Tick) -> Result<()> {
|
||||
let (eff, par) = clip_target(lane);
|
||||
let v = clamp_lane(lane, v);
|
||||
s.edit_sequence("Audio Clip Mixer", move |q, _, _| {
|
||||
let tr = q.mix_track_mut(track).ok_or_else(|| EngineError::Other("no such track".into()))?;
|
||||
let idx = tr.items.iter().position(|c| c.start <= t && t < c.end()).ok_or_else(|| EngineError::Other("no clip under the playhead".into()))?;
|
||||
let c = &mut tr.items[idx];
|
||||
let mt = c.source_time_at(t);
|
||||
let p = c.effect_mut(eff).and_then(|e| e.param_mut(par)).ok_or_else(|| EngineError::Other(format!("the clip has no {eff} effect")))?;
|
||||
if p.is_animated() {
|
||||
p.put_keyframe(mt, ParamValue::Float(v));
|
||||
} else {
|
||||
p.value = ParamValue::Float(v);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn clip_track_p(s: &Session, p: &Value, cmd: &str) -> Result<TrackId> {
|
||||
let id = strip_p(s, p, cmd)?;
|
||||
if !active_seq(s)?.audio_tracks.iter().any(|t| t.id == id) {
|
||||
return Err(bad(cmd, "the Audio Clip Mixer has audio tracks only"));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- helpers
|
||||
|
||||
fn active_seq(s: &Session) -> Result<&Sequence> {
|
||||
|
|
@ -353,7 +505,7 @@ fn lane_p(s: &Session, p: &Value, strip: TrackId, cmd: &str) -> Result<String> {
|
|||
if !ok {
|
||||
return Err(bad(cmd, format!("no lane `{lane}` on this strip (volume, pan, mute, send.<i>.level, fx.<slot>.<param>)")));
|
||||
}
|
||||
if strip == MASTER_STRIP && (lane == LANE_PAN || lane == LANE_MUTE || parse_send_lane(&lane).is_some()) {
|
||||
if strip == MASTER_STRIP && (lane == LANE_PAN || lane == LANE_MUTE || PAN51_LANES.contains(&lane.as_str()) || parse_send_lane(&lane).is_some()) {
|
||||
return Err(bad(cmd, "the Mix track has volume and effect lanes only"));
|
||||
}
|
||||
Ok(lane)
|
||||
|
|
@ -398,7 +550,7 @@ fn mode_p(p: &Value) -> Option<AutomationMode> {
|
|||
str_p(p, "mode").and_then(AutomationMode::from_name)
|
||||
}
|
||||
|
||||
fn channels_name(c: AudioChannels) -> &'static str {
|
||||
pub(crate) fn channels_name(c: AudioChannels) -> &'static str {
|
||||
match c {
|
||||
AudioChannels::Mono => "Mono",
|
||||
AudioChannels::Stereo => "Stereo",
|
||||
|
|
@ -407,10 +559,11 @@ fn channels_name(c: AudioChannels) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
fn channels_from(s: &str) -> Option<AudioChannels> {
|
||||
/// Parse a channel format / track type name (`Mono`, `Stereo` or `Standard`, `5.1`, `Adaptive`).
|
||||
pub(crate) fn channels_from(s: &str) -> Option<AudioChannels> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"mono" => Some(AudioChannels::Mono),
|
||||
"stereo" => Some(AudioChannels::Stereo),
|
||||
"stereo" | "standard" => Some(AudioChannels::Stereo),
|
||||
"5.1" | "surround51" => Some(AudioChannels::Surround51),
|
||||
"adaptive" => Some(AudioChannels::Adaptive),
|
||||
_ => None,
|
||||
|
|
@ -455,6 +608,9 @@ pub fn strip_json(s: &Session, seq: &Sequence, id: TrackId, t: Tick) -> Value {
|
|||
"inserts": tr.effects.iter().enumerate().map(|(i, e)| json!({"slot": i, "effect": e.effect, "name": e.def().map(|d| d.name).unwrap_or(""), "enabled": e.enabled, "postFader": e.post_fader, "params": insert_params(e)})).collect::<Vec<_>>(),
|
||||
"sends": tr.mixer.sends.iter().enumerate().map(|(i, sd)| json!({"send": i, "target": strip_label(seq, sd.target), "levelDb": sd.level_db, "pan": sd.pan, "preFader": sd.pre_fader, "muted": sd.muted})).collect::<Vec<_>>(),
|
||||
"at": {"time": t.0, "volumeDb": shown(LANE_VOLUME), "pan": shown(LANE_PAN), "mute": shown(LANE_MUTE) >= 0.5},
|
||||
"outputChannels": channels_name(seq.output_channels(id)),
|
||||
"pans51": seq.pans_51(id),
|
||||
"pan51": {"x": shown(LANE_PAN51_X), "y": shown(LANE_PAN51_Y), "center": shown(pm::LANE_PAN51_CENTER), "lfeDb": shown(pm::LANE_PAN51_LFE)},
|
||||
"lanes": lanes,
|
||||
})
|
||||
}
|
||||
|
|
@ -469,6 +625,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"gestures": s.mixrec.gestures.iter().map(|g| json!({"strip": strip_label(seq, g.strip), "lane": g.lane, "mode": g.mode.label(), "touching": g.touching, "points": g.points.len(), "value": g.value()})).collect::<Vec<_>>(),
|
||||
"latencySamples": filmcraft_render::mixer::graph_latency(seq),
|
||||
"automatchTime": s.prefs.audio.automatch_time,
|
||||
"clipModes": seq.audio_tracks.iter().map(|t| json!({"track": strip_label(seq, t.id), "mode": clip_mode(s, t.id).label()})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -1123,6 +1280,52 @@ pub fn commands() -> Vec<CommandSpec> {
|
|||
clip_set,
|
||||
true,
|
||||
),
|
||||
spec(
|
||||
"clipMixer.setMode",
|
||||
"Audio Clip Mixer Automation Mode",
|
||||
&[],
|
||||
None,
|
||||
r#"{"track":"A1"|id,"mode":"Off|Read|Latch|Touch|Write"}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let id = clip_track_p(s, p, "clipMixer.setMode")?;
|
||||
let m = mode_p(p).ok_or_else(|| bad("clipMixer.setMode", "need `mode` (Off, Read, Latch, Touch, Write)"))?;
|
||||
s.state.clip_mixer_modes.insert(id.0, m);
|
||||
Ok(json!({"track": id.0, "mode": m.label()}))
|
||||
},
|
||||
false,
|
||||
),
|
||||
spec(
|
||||
"clipMixer.touch",
|
||||
"Touch Clip Mixer Control",
|
||||
&[],
|
||||
None,
|
||||
r#"{"track":"A1"|id,"lane":"volume|pan","value":f64,"time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let id = clip_track_p(s, p, "clipMixer.touch")?;
|
||||
let lane = clip_lane_p(p);
|
||||
let v = f64_p(p, "value").ok_or_else(|| bad("clipMixer.touch", "need `value`"))?;
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
touch(s, id, &lane, v, t)
|
||||
},
|
||||
false,
|
||||
),
|
||||
spec(
|
||||
"clipMixer.release",
|
||||
"Release Clip Mixer Control",
|
||||
&[],
|
||||
None,
|
||||
r#"{"track":"A1"|id,"lane":"volume|pan","time":ticks?}"#,
|
||||
has_seq,
|
||||
|s, p| {
|
||||
let id = clip_track_p(s, p, "clipMixer.release")?;
|
||||
let lane = clip_lane_p(p);
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
release(s, id, &lane, t)
|
||||
},
|
||||
true,
|
||||
),
|
||||
spec(
|
||||
"effects.setDefaultTransition",
|
||||
"Set Selected as Default Transition",
|
||||
|
|
|
|||
|
|
@ -324,3 +324,160 @@ fn default_audio_transition_is_used_by_apply() {
|
|||
assert_eq!(tr.len(), 1);
|
||||
assert_eq!(tr[0].effect.effect, "exponential_fade");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- Audio Clip Mixer automation
|
||||
|
||||
/// The first clip on A1 and a time range well inside it.
|
||||
fn a1_clip(s: &Session) -> (filmcraft_project::ClipId, Tick, Tick) {
|
||||
let c = &a1(s).items[0];
|
||||
let len = c.duration.0;
|
||||
(c.id, Tick(c.start.0 + len / 5), Tick(c.start.0 + len * 4 / 5))
|
||||
}
|
||||
|
||||
fn clip_level(s: &Session, clip: filmcraft_project::ClipId, t: Tick) -> f64 {
|
||||
let (_, c) = s.active_sequence().unwrap().find_item(clip).unwrap();
|
||||
c.effect("volume").unwrap().f64_at("level", c.source_time_at(t))
|
||||
}
|
||||
|
||||
fn clip_level_kfs(s: &Session, clip: filmcraft_project::ClipId) -> usize {
|
||||
let (_, c) = s.active_sequence().unwrap().find_item(clip).unwrap();
|
||||
c.effect("volume").unwrap().param("level").unwrap().keyframes.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_mixer_touch_writes_clip_keyframes_and_ramps_back() {
|
||||
let mut s = demo();
|
||||
s.prefs.audio.automatch_time = 0.0;
|
||||
let (clip, t0, t1) = a1_clip(&s);
|
||||
let base = clip_level(&s, clip, t0);
|
||||
let kf0 = clip_level_kfs(&s, clip);
|
||||
let mid = Tick((t0.0 + t1.0) / 2);
|
||||
assert!(s.execute("clipMixer.setMode", json!({"track": "Mix", "mode": "Touch"})).is_err(), "audio tracks only");
|
||||
s.execute("clipMixer.setMode", json!({"track": "A1", "mode": "Touch"})).unwrap();
|
||||
assert_eq!(s.execute("mixer.inspect", json!({})).unwrap()["clipModes"][0]["mode"], "Touch");
|
||||
let undo0 = s.history.undo.len();
|
||||
s.execute("mixer.recordStart", json!({"time": t0.0})).unwrap();
|
||||
// a dense linear fader move from 0 to −12 dB over [t0, mid], then let go
|
||||
let steps = 200;
|
||||
for i in 0..=steps {
|
||||
let t = Tick(t0.0 + (mid.0 - t0.0) * i / steps);
|
||||
let r = s.execute("clipMixer.touch", json!({"track": "A1", "lane": "volume", "value": -12.0 * i as f64 / steps as f64, "time": t.0})).unwrap();
|
||||
assert_eq!(r["recording"], true);
|
||||
}
|
||||
// the held value is heard live on the track
|
||||
let tid = a1(&s).id;
|
||||
assert_eq!(s.previews.live.get(tid, filmcraft_render::audio::CLIP_LANE_VOLUME).map(|o| o.value), Some(-12.0));
|
||||
s.execute("clipMixer.release", json!({"track": "A1", "lane": "volume", "time": mid.0})).unwrap();
|
||||
let r = s.execute("mixer.recordStop", json!({"time": t1.0})).unwrap();
|
||||
assert_eq!(r["lanes"], 1);
|
||||
assert_eq!(s.history.undo.len(), undo0 + 1, "one undo step");
|
||||
// thinned: the 201-point ramp is two keyframes plus the boundary keyframes
|
||||
let n = clip_level_kfs(&s, clip);
|
||||
assert!((2..=6).contains(&n), "{n} keyframes");
|
||||
assert!((clip_level(&s, clip, t0) - 0.0).abs() < 0.06);
|
||||
assert!((clip_level(&s, clip, Tick((t0.0 + mid.0) / 2)) + 6.0).abs() < 0.1, "{}", clip_level(&s, clip, Tick((t0.0 + mid.0) / 2)));
|
||||
assert!((clip_level(&s, clip, Tick(mid.0 - 1)) + 12.0).abs() < 0.1);
|
||||
// outside the gesture the clip keeps its old value; after the release (automatch 0)
|
||||
// it is back to the automation at once
|
||||
let c_start = s.active_sequence().unwrap().find_item(clip).unwrap().1.start;
|
||||
assert!((clip_level(&s, clip, Tick(c_start.0 + 1)) - base).abs() < 1e-9);
|
||||
assert!((clip_level(&s, clip, Tick(t1.0 - 1)) - base).abs() < 1e-9, "{}", clip_level(&s, clip, Tick(t1.0 - 1)));
|
||||
assert!(s.previews.live.get(tid, filmcraft_render::audio::CLIP_LANE_VOLUME).is_none(), "overrides cleared");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(clip_level_kfs(&s, clip), kf0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_mixer_latch_and_write_hold_until_stop() {
|
||||
let mut s = demo();
|
||||
let (clip, t0, t1) = a1_clip(&s);
|
||||
let mid = Tick((t0.0 + t1.0) / 2);
|
||||
let base = clip_level(&s, clip, t0);
|
||||
// Latch: from the first touch, the last value holds until playback stops
|
||||
s.execute("clipMixer.setMode", json!({"track": "A1", "mode": "Latch"})).unwrap();
|
||||
s.execute("mixer.recordStart", json!({"time": t0.0})).unwrap();
|
||||
s.execute("clipMixer.touch", json!({"track": "A1", "lane": "volume", "value": -6.0, "time": mid.0})).unwrap();
|
||||
s.execute("clipMixer.release", json!({"track": "A1", "lane": "volume", "time": mid.0 + sec(0.1)})).unwrap();
|
||||
s.execute("mixer.recordStop", json!({"time": t1.0})).unwrap();
|
||||
assert!((clip_level(&s, clip, Tick(t0.0 + sec(0.01))) - base).abs() < 1e-9, "untouched before the first touch");
|
||||
assert!((clip_level(&s, clip, Tick(t1.0 - sec(0.01))) + 6.0).abs() < 1e-6, "latched");
|
||||
// Write: records from the start of playback (pan too), even without a touch
|
||||
let mut s = demo();
|
||||
let (clip, t0, t1) = a1_clip(&s);
|
||||
s.execute("clipMixer.setMode", json!({"track": "A1", "mode": "Write"})).unwrap();
|
||||
let r = s.execute("mixer.recordStart", json!({"time": t0.0})).unwrap();
|
||||
assert_eq!(r["writing"], 2, "volume and pan");
|
||||
s.execute("clipMixer.touch", json!({"track": "A1", "lane": "pan", "value": -40.0, "time": t0.0 + sec(0.2)})).unwrap();
|
||||
s.execute("mixer.recordStop", json!({"time": t1.0})).unwrap();
|
||||
let (_, c) = s.active_sequence().unwrap().find_item(clip).unwrap();
|
||||
let bal = c.effect("panner").unwrap().f64_at("balance", c.source_time_at(Tick(t1.0 - sec(0.01))));
|
||||
assert!((bal + 40.0).abs() < 1e-6, "{bal}");
|
||||
// Read mode: touching does not record; without a pass a release commits the value once
|
||||
let mut s = demo();
|
||||
let (clip, t0, _) = a1_clip(&s);
|
||||
let kf0 = clip_level_kfs(&s, clip);
|
||||
let r = s.execute("clipMixer.touch", json!({"track": "A1", "lane": "volume", "value": -3.0, "time": t0.0})).unwrap();
|
||||
assert_eq!(r["recording"], false);
|
||||
s.execute("clipMixer.release", json!({"track": "A1", "lane": "volume", "time": t0.0})).unwrap();
|
||||
assert!((clip_level(&s, clip, t0) + 3.0).abs() < 1e-9);
|
||||
assert_eq!(clip_level_kfs(&s, clip), kf0, "static value, no keyframe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_automation_spanning_two_clips_writes_both() {
|
||||
let mut s = demo();
|
||||
let tr = a1(&s).clone();
|
||||
if tr.items.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let (c0, c1) = (&tr.items[0], &tr.items[1]);
|
||||
let (ta, tb) = (Tick(c0.end().0 - sec(0.5)), Tick(c1.start.0 + sec(0.5)));
|
||||
let base1 = clip_level(&s, c1.id, Tick(c1.start.0 + sec(1.0)));
|
||||
s.execute("clipMixer.setMode", json!({"track": "A1", "mode": "Latch"})).unwrap();
|
||||
s.execute("mixer.recordStart", json!({"time": ta.0})).unwrap();
|
||||
s.execute("clipMixer.touch", json!({"track": "A1", "lane": "volume", "value": -9.0, "time": ta.0})).unwrap();
|
||||
s.execute("mixer.recordStop", json!({"time": tb.0})).unwrap();
|
||||
assert!((clip_level(&s, c0.id, Tick(c0.end().0 - sec(0.1))) + 9.0).abs() < 1e-6);
|
||||
assert!((clip_level(&s, c1.id, Tick(c1.start.0 + sec(0.1))) + 9.0).abs() < 1e-6);
|
||||
assert!((clip_level(&s, c1.id, Tick(c1.start.0 + sec(1.0))) - base1).abs() < 1e-9, "after the range: unchanged");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- 5.1
|
||||
|
||||
#[test]
|
||||
fn surround_sequence_settings_and_panner_lanes() {
|
||||
let mut s = demo();
|
||||
assert_eq!(s.execute("mixer.inspect", json!({})).unwrap()["strips"][0]["pans51"], false);
|
||||
s.execute("sequence.settings", json!({"mix": "5.1"})).unwrap();
|
||||
assert_eq!(s.active_sequence().unwrap().settings.audio_master, filmcraft_project::AudioChannels::Surround51);
|
||||
let v = s.execute("mixer.inspect", json!({})).unwrap();
|
||||
assert_eq!(v["strips"][0]["pans51"], true);
|
||||
assert_eq!(v["strips"][0]["outputChannels"], "5.1");
|
||||
assert_eq!(v["strips"].as_array().unwrap().last().unwrap()["channels"], "5.1");
|
||||
s.execute("mixer.setValue", json!({"strip": "A1", "lane": "pan51.x", "value": -50.0})).unwrap();
|
||||
s.execute("mixer.setValue", json!({"strip": "A1", "lane": "pan51.center", "value": 250.0})).unwrap();
|
||||
let v = s.execute("mixer.inspect", json!({})).unwrap();
|
||||
assert_eq!(v["strips"][0]["pan51"]["x"], -50.0);
|
||||
assert_eq!(v["strips"][0]["pan51"]["center"], 100.0, "clamped");
|
||||
assert!(s.execute("mixer.setValue", json!({"strip": "Mix", "lane": "pan51.x", "value": 1.0})).is_err());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute("mixer.inspect", json!({})).unwrap()["strips"][0]["pan51"]["x"], 0.0);
|
||||
// the mix is six channels; mix_sequence folds it to stereo
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(filmcraft_render::mixer::mix_graph(&s.project, q, 0, 480, &provider, None).channels.len(), 6);
|
||||
assert_eq!(filmcraft_render::audio::mix_sequence(&s.project, q, 0, 480, &provider).channels.len(), 2);
|
||||
// a Write pass on a strip feeding 5.1 records the puck, not the stereo pan
|
||||
set_mode(&mut s, "A1", "Write");
|
||||
let r = s.execute("mixer.recordStart", json!({"time": 0})).unwrap();
|
||||
assert_eq!(r["writing"], 4);
|
||||
s.execute("mixer.recordStop", json!({"time": sec(1.0)})).unwrap();
|
||||
// New Sequence with a 5.1 Mix and 5.1 tracks
|
||||
s.execute("file.newSequence", json!({"name": "surround", "mix": "5.1", "trackType": "5.1", "audio": 2})).unwrap();
|
||||
let q = s.active_sequence().unwrap();
|
||||
assert_eq!(q.settings.audio_master, filmcraft_project::AudioChannels::Surround51);
|
||||
assert!(q.audio_tracks.iter().all(|t| t.channels == filmcraft_project::AudioChannels::Surround51));
|
||||
assert!(s.execute("file.newSequence", json!({"mix": "7.1"})).is_err());
|
||||
s.execute("file.newSequence", json!({"trackType": "Standard"})).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
//! Playback asks [`PreviewStore::frame`] first: when the frame lies in a segment with a preview, the
|
||||
//! decoded preview frame replaces the live render.
|
||||
|
||||
use filmcraft_audio_dsp::channels::{Layout, Mixdown};
|
||||
use filmcraft_render::audio::to_layout;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
|
@ -257,15 +259,35 @@ impl PreviewStore {
|
|||
Some(samples)
|
||||
}
|
||||
|
||||
/// Sequence audio for playback and meters: rendered audio previews where they are valid, the
|
||||
/// live mix ([`filmcraft_render::audio::mix_sequence`]) everywhere else.
|
||||
/// Sequence audio for playback and meters, as stereo (a 5.1 Mix folded with the BS.775
|
||||
/// downmix): rendered audio previews where they are valid, the live mix everywhere else.
|
||||
pub fn mix(&self, project: &Arc<Project>, seq: ItemId, start: i64, frames: usize, sources: &dyn filmcraft_render::SourceProvider) -> AudioBuffer {
|
||||
let Some(q) = project.sequence(seq) else { return AudioBuffer::silence(48_000, 2, frames) };
|
||||
self.mix_layout(project, seq, start, frames, sources, Layout::Stereo, Mixdown::FrontRear)
|
||||
}
|
||||
|
||||
/// [`PreviewStore::mix`] in `layout`: a 5.1 Mix is folded to stereo with `mixdown` (Preferences ▸
|
||||
/// Audio ▸ 5.1 Mixdown Type) or played as six channels on a 5.1 device; a stereo Mix is placed
|
||||
/// on the front speakers of a 5.1 device. Rendered audio previews are stereo BS.775 folds, so
|
||||
/// they are used only for stereo output of a stereo Mix or with the BS.775 mixdown.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn mix_layout(
|
||||
&self,
|
||||
project: &Arc<Project>,
|
||||
seq: ItemId,
|
||||
start: i64,
|
||||
frames: usize,
|
||||
sources: &dyn filmcraft_render::SourceProvider,
|
||||
layout: Layout,
|
||||
mixdown: Mixdown,
|
||||
) -> AudioBuffer {
|
||||
let Some(q) = project.sequence(seq) else { return AudioBuffer::silence(48_000, layout.channels(), frames) };
|
||||
let live = Some(&*self.live);
|
||||
let has_audio = self.files.read().unwrap_or_else(|e| e.into_inner()).iter().any(|n| n.ends_with(".wav"));
|
||||
let surround = q.settings.audio_master == filmcraft_project::AudioChannels::Surround51;
|
||||
let previews_ok = layout == Layout::Stereo && (!surround || mixdown == Mixdown::FrontRear);
|
||||
// held mixer controls are heard live (rendered previews don't know them)
|
||||
if !has_audio || self.live.is_active() {
|
||||
return filmcraft_render::mixer::mix_graph(project, q, start, frames, sources, live);
|
||||
if !has_audio || self.live.is_active() || !previews_ok {
|
||||
return to_layout(filmcraft_render::mixer::mix_graph(project, q, start, frames, sources, live), layout, mixdown);
|
||||
}
|
||||
let segs = self.audio_segments(project, seq);
|
||||
let mut out = AudioBuffer::silence(q.settings.sample_rate, 2, frames);
|
||||
|
|
@ -290,7 +312,7 @@ impl PreviewStore {
|
|||
}
|
||||
}
|
||||
_ => {
|
||||
let b = filmcraft_render::mixer::mix_graph(project, q, pos, n, sources, live);
|
||||
let b = to_layout(filmcraft_render::mixer::mix_graph(project, q, pos, n, sources, live), Layout::Stereo, mixdown);
|
||||
for c in 0..2 {
|
||||
out.channels[c][off..off + n].copy_from_slice(&b.channels[c.min(b.channels.len() - 1)][..n]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
//! Clip ▸ Remix: retime a music clip to a target duration by cutting it at musically similar beat
|
||||
//! boundaries (Premiere's Remix; analysis and planning in [`filmcraft_audio_dsp::remix`], playback
|
||||
//! in [`filmcraft_render::remix`]).
|
||||
//!
|
||||
//! | command | menu | what it does |
|
||||
//! |---|---|---|
|
||||
//! | `clip.remix.enable` | Clip ▸ Remix ▸ Enable Remix | analyses the selected audio clip and makes it a remix clip at its current duration |
|
||||
//! | `clip.remix.properties` | Clip ▸ Remix ▸ Remix Properties… | without parameters returns the settings; with `duration`/`seconds`/`frame`/`timecode`, `segments`, `variations` re-plans |
|
||||
//! | `clip.remix.revert` | Clip ▸ Remix ▸ Revert Remix | restores the clip as it was before the remix |
|
||||
//! | `clip.remix` | — | agents: enable (if needed) and remix `clip` (default: the selected audio clip) to a target |
|
||||
//!
|
||||
//! The remix keeps the clip's start and source In, plays the intro from the beginning and ends on
|
||||
//! the source's end; only the duration changes. The clip's linked partners are not changed. When
|
||||
//! the remixed clip would run into the next clip on its track the command is refused (nothing is
|
||||
//! trimmed or moved); remixing shorter always fits. Speed changes and reverse do not apply to a
|
||||
//! remixed clip (enabling resets them to 100 %).
|
||||
//!
|
||||
//! The analysis of a clip (beats, beat self-similarity) is cached per media range and rate, so
|
||||
//! changing the target or the sliders re-plans instantly. Everything is deterministic.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use filmcraft_audio_dsp::remix::{self as dsp, Analysis, Params};
|
||||
use filmcraft_project::{ClipId, ItemId, TrackKind};
|
||||
use filmcraft_render::remix::{Remix, TickPiece};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, bad, f64_p, has_seq, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
type Key = (u64, i64, i64, u32);
|
||||
|
||||
fn cache() -> &'static Mutex<Vec<(Key, Arc<Analysis>)>> {
|
||||
static C: OnceLock<Mutex<Vec<(Key, Arc<Analysis>)>>> = OnceLock::new();
|
||||
C.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The audio clip a menu command works on: the first selected clip on an audio track.
|
||||
fn selected_audio(s: &Session) -> Option<ClipId> {
|
||||
let seq = s.active_sequence()?;
|
||||
s.state.selection.iter().copied().find(|c| seq.find_item(*c).and_then(|(t, _)| seq.track(t)).is_some_and(|t| t.kind == TrackKind::Audio))
|
||||
}
|
||||
|
||||
fn check_clip(s: &Session, c: ClipId) -> std::result::Result<(), String> {
|
||||
let seq = s.active_sequence().ok_or("no sequence is open")?;
|
||||
let (t, it) = seq.find_item(c).ok_or("no such clip")?;
|
||||
if seq.track(t).is_none_or(|t| t.kind != TrackKind::Audio) {
|
||||
return Err("Remix works on audio clips".into());
|
||||
}
|
||||
let item = s.project.item(it.item).ok_or("the clip has no media")?;
|
||||
if !matches!(item.kind, filmcraft_project::ItemKind::Media(_)) || !item.has_audio() {
|
||||
return Err("the clip has no audio media to remix".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn can_enable(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
let c = selected_audio(s).ok_or("select an audio clip")?;
|
||||
check_clip(s, c)
|
||||
}
|
||||
|
||||
fn is_remixed(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
let c = selected_audio(s).ok_or("select an audio clip")?;
|
||||
let seq = s.active_sequence().ok_or("no sequence is open")?;
|
||||
if seq.find_item(c).is_some_and(|(_, i)| filmcraft_render::remix::is_remixed(i)) { Ok(()) } else { Err("the selected clip is not remixed".into()) }
|
||||
}
|
||||
|
||||
fn target_clip(s: &Session, p: &Value, cmd: &str) -> Result<ClipId> {
|
||||
let c = match u64_p(p, "clip") {
|
||||
Some(c) => ClipId(c),
|
||||
None => selected_audio(s).ok_or_else(|| bad(cmd, "select an audio clip or pass `clip`"))?,
|
||||
};
|
||||
check_clip(s, c).map_err(|e| bad(cmd, e))?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Target duration from `duration` (ticks), `seconds`, `frame` or `timecode` (a duration).
|
||||
fn duration_p(s: &Session, p: &Value) -> Option<Tick> {
|
||||
if let Some(d) = p.get("duration").and_then(Value::as_i64) {
|
||||
return Some(Tick(d));
|
||||
}
|
||||
let rate = s.sequence_rate();
|
||||
if let Some(sec) = f64_p(p, "seconds") {
|
||||
return Some(Tick::from_seconds_f64(sec));
|
||||
}
|
||||
if let Some(f) = p.get("frame").or_else(|| p.get("frames")).and_then(Value::as_i64) {
|
||||
return Some(rate.tick_of(f));
|
||||
}
|
||||
if let Some(tc) = p.get("timecode").and_then(Value::as_str) {
|
||||
let df = s.active_sequence().map(|q| q.settings.drop_frame).unwrap_or(false);
|
||||
return filmcraft_time::parse_timecode(tc, rate, df, 0).ok().map(|f| rate.tick_of(f));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Analysis of `[source_in, source_in + dur)` of the clip's media at the sequence rate (cached).
|
||||
fn analysis(s: &Session, item: ItemId, source_in: Tick, dur: Tick) -> Result<Arc<Analysis>> {
|
||||
let sr = s.active_sequence().map(|q| q.settings.sample_rate).unwrap_or(48_000).max(1);
|
||||
let m0 = source_in.to_units_floor(sr as i64);
|
||||
let len = (source_in + dur).to_units_floor(sr as i64) - m0;
|
||||
let key = (item.0, m0, len, sr);
|
||||
if let Some((_, a)) = cache().lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(k, _)| *k == key) {
|
||||
return Ok(a.clone());
|
||||
}
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
let src = filmcraft_render::SourceProvider::source(&provider, item).ok_or_else(|| EngineError::Other("the clip's media is offline".into()))?;
|
||||
let buf = src.audio(m0, len.max(0) as usize, sr).map_err(|e| EngineError::Other(format!("reading the clip's audio: {e}")))?;
|
||||
let refs: Vec<&[f32]> = buf.channels.iter().map(Vec::as_slice).collect();
|
||||
let a = Arc::new(dsp::analyze(&refs, sr).map_err(|e| EngineError::Other(format!("cannot remix: {e}")))?);
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
c.push((key, a.clone()));
|
||||
if c.len() > 8 {
|
||||
c.remove(0);
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// The remix of `c` to `target` with the given sliders: (new duration, state).
|
||||
fn make_plan(s: &Session, c: ClipId, target: Tick, segments: f64, variations: f64) -> Result<(Tick, Remix, Arc<Analysis>)> {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let sr = seq.settings.sample_rate.max(1) as i64;
|
||||
let (_, it) = seq.find_item(c).ok_or_else(|| EngineError::Other("no such clip".into()))?;
|
||||
let old = filmcraft_render::remix::Remix::of(it);
|
||||
let original = old.as_ref().map(|r| r.original).unwrap_or(it.duration);
|
||||
let a = analysis(s, it.item, it.source_in, original)?;
|
||||
let params = Params { segments: segments.clamp(0.0, 100.0), variations: variations.clamp(0.0, 100.0) };
|
||||
let plan = dsp::plan(&a, target.to_units_floor(sr), params).map_err(|e| EngineError::Other(format!("cannot remix: {e}")))?;
|
||||
let pieces = plan.pieces.iter().map(|p| TickPiece { src: it.source_in + Tick::from_units(p.src, sr), len: Tick::from_units(p.len, sr) }).collect();
|
||||
let rm = Remix { target, segments: params.segments, variations: params.variations, original, xfade: Tick::from_units(plan.xfade, sr), pieces };
|
||||
Ok((Tick::from_units(plan.len(), sr), rm, a))
|
||||
}
|
||||
|
||||
/// Apply `rm` to clip `c` with duration `dur` (one undo step); refuses overlaps.
|
||||
fn apply(s: &mut Session, label: &str, c: ClipId, dur: Tick, rm: Option<Remix>) -> Result<()> {
|
||||
s.edit_sequence(label, move |q, _, _| {
|
||||
let (tid, it) = q.find_item(c).ok_or_else(|| EngineError::Other("no such clip".into()))?;
|
||||
let start = it.start;
|
||||
let tr = q.track(tid).ok_or_else(|| EngineError::Other("no such track".into()))?;
|
||||
if let Some(next) = tr.items.iter().filter(|i| i.id != c && i.start >= start).map(|i| i.start).min()
|
||||
&& start + dur > next
|
||||
{
|
||||
return Err(EngineError::Other(format!("not enough room: the remixed clip ({:.2} s) would overlap the next clip on {}", dur.seconds(), tr.name)));
|
||||
}
|
||||
let (_, it) = q.find_item_mut(c).ok_or_else(|| EngineError::Other("no such clip".into()))?;
|
||||
it.duration = dur;
|
||||
match rm {
|
||||
Some(r) => {
|
||||
it.speed = 1.0;
|
||||
it.reverse = false;
|
||||
r.store(it);
|
||||
}
|
||||
None => {
|
||||
filmcraft_render::remix::take(it);
|
||||
}
|
||||
}
|
||||
if let Some(t) = q.track_mut(tid) {
|
||||
t.sort();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn report(s: &Session, c: ClipId, a: Option<&Analysis>) -> Value {
|
||||
let Some(seq) = s.active_sequence() else { return Value::Null };
|
||||
let Some((_, it)) = seq.find_item(c) else { return Value::Null };
|
||||
let rm = Remix::of(it);
|
||||
let mut v = json!({
|
||||
"clip": c.0,
|
||||
"remixed": rm.is_some(),
|
||||
"duration": it.duration.0,
|
||||
"seconds": it.duration.seconds(),
|
||||
});
|
||||
if let Some(r) = rm {
|
||||
let cuts: Vec<Value> = r.pieces.windows(2).map(|w| json!({"out": (w[0].src + w[0].len).0, "in": w[1].src.0})).collect();
|
||||
v["target"] = json!(r.target.0);
|
||||
v["targetSeconds"] = json!(r.target.seconds());
|
||||
v["originalSeconds"] = json!(r.original.seconds());
|
||||
v["segments"] = json!(r.segments);
|
||||
v["variations"] = json!(r.variations);
|
||||
v["pieces"] = Value::Array(r.pieces.iter().map(|p| json!({"src": p.src.0, "len": p.len.0})).collect());
|
||||
v["cuts"] = Value::Array(cuts);
|
||||
v["errorSeconds"] = json!((it.duration - r.target).seconds());
|
||||
}
|
||||
if let Some(a) = a {
|
||||
v["bpm"] = json!(a.bpm);
|
||||
v["beats"] = json!(a.beats.len());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn enable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = target_clip(s, p, "clip.remix.enable")?;
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (_, it) = seq.find_item(c).ok_or_else(|| bad("clip.remix.enable", "no such clip"))?;
|
||||
if filmcraft_render::remix::is_remixed(it) {
|
||||
return Ok(report(s, c, None));
|
||||
}
|
||||
let dur = it.duration;
|
||||
let (d, rm, a) = make_plan(s, c, dur, 50.0, 50.0)?;
|
||||
apply(s, "Enable Remix", c, d, Some(rm))?;
|
||||
Ok(report(s, c, Some(&a)))
|
||||
}
|
||||
|
||||
fn remix(s: &mut Session, p: &Value, cmd: &str, label: &str) -> Result<Value> {
|
||||
let c = target_clip(s, p, cmd)?;
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (_, it) = seq.find_item(c).ok_or_else(|| bad(cmd, "no such clip"))?;
|
||||
let cur = Remix::of(it);
|
||||
let target = duration_p(s, p);
|
||||
let segments = f64_p(p, "segments");
|
||||
let variations = f64_p(p, "variations");
|
||||
if target.is_none() && segments.is_none() && variations.is_none() {
|
||||
if cmd == "clip.remix" {
|
||||
return Err(bad(cmd, "need a target `duration` (ticks), `seconds`, `frame` or `timecode`"));
|
||||
}
|
||||
return Ok(report(s, c, None));
|
||||
}
|
||||
let target = target.or(cur.as_ref().map(|r| r.target)).unwrap_or(it.duration);
|
||||
if target.0 <= 0 {
|
||||
return Err(bad(cmd, "the target duration must be positive"));
|
||||
}
|
||||
let segments = segments.or(cur.as_ref().map(|r| r.segments)).unwrap_or(50.0);
|
||||
let variations = variations.or(cur.as_ref().map(|r| r.variations)).unwrap_or(50.0);
|
||||
let (d, rm, a) = make_plan(s, c, target, segments, variations)?;
|
||||
apply(s, label, c, d, Some(rm))?;
|
||||
Ok(report(s, c, Some(&a)))
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = target_clip(s, p, "clip.remix.revert")?;
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
let (_, it) = seq.find_item(c).ok_or_else(|| bad("clip.remix.revert", "no such clip"))?;
|
||||
let rm = Remix::of(it).ok_or_else(|| bad("clip.remix.revert", "the clip is not remixed"))?;
|
||||
apply(s, "Revert Remix", c, rm.original, None)?;
|
||||
Ok(report(s, c, None))
|
||||
}
|
||||
|
||||
/// Remix state of every remixed clip in the active sequence (for agents and the UI).
|
||||
pub fn remixed_clips(s: &Session) -> HashMap<ClipId, Remix> {
|
||||
let mut out = HashMap::new();
|
||||
if let Some(q) = s.active_sequence() {
|
||||
for t in &q.audio_tracks {
|
||||
for i in &t.items {
|
||||
if let Some(r) = Remix::of(i) {
|
||||
out.insert(i.id, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<CommandSpec> {
|
||||
const PARAMS: &str = r#"{"clip":id?,"duration":ticks?,"seconds":f64?,"frame":n?,"timecode":str?,"segments":0..100?,"variations":0..100?}"#;
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "clip.remix.enable",
|
||||
label: "Enable Remix",
|
||||
menu: &["Clip", "Remix"],
|
||||
shortcut: None,
|
||||
params: r#"{"clip":id?}"#,
|
||||
enabled: can_enable,
|
||||
run: enable,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "clip.remix.properties",
|
||||
label: "Remix Properties…",
|
||||
menu: &["Clip", "Remix"],
|
||||
shortcut: None,
|
||||
params: PARAMS,
|
||||
enabled: is_remixed,
|
||||
run: |s, p| remix(s, p, "clip.remix.properties", "Remix Properties"),
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "clip.remix.revert",
|
||||
label: "Revert Remix",
|
||||
menu: &["Clip", "Remix"],
|
||||
shortcut: None,
|
||||
params: r#"{"clip":id?}"#,
|
||||
enabled: is_remixed,
|
||||
run: revert,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "clip.remix",
|
||||
label: "Remix",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: PARAMS,
|
||||
enabled: has_seq,
|
||||
run: |s, p| remix(s, p, "clip.remix", "Remix"),
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
//! Tests of [`crate::remix`]: Clip ▸ Remix on generated rhythmic music (120 BPM, 16 bars, 32 s).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_audio_dsp::remix as dsp;
|
||||
use filmcraft_project::{ClipId, TrackItem};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
const SR: u32 = 48_000;
|
||||
|
||||
/// Generated music (stereo planar) and its analysis.
|
||||
fn music() -> (Vec<Vec<f32>>, dsp::Analysis) {
|
||||
let (m, _) = dsp::test_music(SR, 120.0, 16, 3);
|
||||
let refs: Vec<&[f32]> = m.iter().map(Vec::as_slice).collect();
|
||||
let a = dsp::analyze(&refs, SR).unwrap();
|
||||
(m, a)
|
||||
}
|
||||
|
||||
/// A session with `music` on A1 at 0 s (and optionally a second clip at `next` s), the music
|
||||
/// clip selected.
|
||||
fn session(m: &[Vec<f32>], next: Option<f64>) -> (Session, ClipId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "remix", "audio": 2, "video": 1})).unwrap();
|
||||
let inter: Vec<f32> = (0..m[0].len()).flat_map(|i| [m[0][i], m[1][i]]).collect();
|
||||
let bytes: Arc<[u8]> = crate::previews::write_wav_f32(&inter, SR).into();
|
||||
let item = crate::commands::import_bytes(&mut s, "/music.wav", bytes, None).unwrap();
|
||||
let r = s.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap();
|
||||
let c = ClipId(r["clips"][0].as_u64().unwrap());
|
||||
if let Some(at) = next {
|
||||
s.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": at})).unwrap();
|
||||
}
|
||||
s.execute("timeline.select", json!({"clips": [c.0]})).unwrap();
|
||||
(s, c)
|
||||
}
|
||||
|
||||
fn item(s: &Session, c: ClipId) -> TrackItem {
|
||||
s.active_sequence().unwrap().find_item(c).unwrap().1.clone()
|
||||
}
|
||||
|
||||
fn beat() -> Tick {
|
||||
Tick::from_seconds_f64(0.5)
|
||||
}
|
||||
|
||||
/// Every cut of the report lies on a detected beat of the music.
|
||||
fn assert_cuts_on_beats(r: &Value, a: &dsp::Analysis) {
|
||||
for cut in r["cuts"].as_array().unwrap() {
|
||||
for k in ["out", "in"] {
|
||||
let t = Tick(cut[k].as_i64().unwrap());
|
||||
let smp = t.to_units_floor(SR as i64);
|
||||
assert!(a.beats.contains(&smp), "cut {k} at sample {smp} is not a detected beat");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_commands_and_disabled_reasons() {
|
||||
let (m, _) = music();
|
||||
let (mut s, c) = session(&m, None);
|
||||
let pos = |id: &str| crate::commands::command_specs().iter().position(|c| c.id == id).unwrap();
|
||||
assert!(pos("clip.remix.enable") < pos("clip.remix.properties") && pos("clip.remix.properties") < pos("clip.remix.revert"));
|
||||
let spec = crate::commands::find("clip.remix.enable").unwrap();
|
||||
assert_eq!(spec.menu, &["Clip", "Remix"]);
|
||||
// not remixed yet: properties / revert are disabled
|
||||
assert!(s.execute("clip.remix.revert", json!({})).is_err());
|
||||
assert!(s.execute("clip.remix.properties", json!({})).is_err());
|
||||
// nothing selected: enable is disabled with a reason
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let why = (crate::commands::find("clip.remix.enable").unwrap().enabled)(&s).unwrap_err();
|
||||
assert!(why.contains("audio clip"), "{why}");
|
||||
s.execute("timeline.select", json!({"clips": [c.0]})).unwrap();
|
||||
assert!((spec.enabled)(&s).is_ok());
|
||||
// agents must say what duration they want
|
||||
assert!(s.execute("clip.remix", json!({"clip": c.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remix_shorter_and_longer_hits_target_at_beats_with_undo() {
|
||||
let (m, a) = music();
|
||||
let (mut s, c) = session(&m, None);
|
||||
let before = item(&s, c);
|
||||
let r = s.execute("clip.remix.enable", json!({})).unwrap();
|
||||
assert_eq!(r["remixed"], true);
|
||||
let enabled = item(&s, c);
|
||||
assert!((before.duration - enabled.duration).abs() < Tick::from_units(1, SR as i64), "enable keeps the duration");
|
||||
let n0 = s.history.undo.len();
|
||||
for secs in [12.0, 20.0, 26.5, 45.0, 70.0] {
|
||||
let r = s.execute("clip.remix", json!({"seconds": secs})).unwrap();
|
||||
let d = item(&s, c).duration;
|
||||
let err = (d - Tick::from_seconds_f64(secs)).abs();
|
||||
assert!(err <= beat(), "{secs} s: off by {:.3} s", err.seconds());
|
||||
assert_eq!(Tick(r["duration"].as_i64().unwrap()), d);
|
||||
assert_cuts_on_beats(&r, &a);
|
||||
assert!(!r["cuts"].as_array().unwrap().is_empty());
|
||||
eprintln!("remix to {secs} s: {:.4} s, {} cuts, {:.1} BPM", d.seconds(), r["cuts"].as_array().unwrap().len(), r["bpm"].as_f64().unwrap());
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), n0 + 5, "one undo step per remix");
|
||||
// properties: read and change the sliders (keeps the target)
|
||||
let p = s.execute("clip.remix.properties", json!({})).unwrap();
|
||||
assert_eq!(p["targetSeconds"].as_f64().unwrap(), 70.0);
|
||||
let few = s.execute("clip.remix.properties", json!({"segments": 0.0})).unwrap();
|
||||
let many = s.execute("clip.remix.properties", json!({"segments": 100.0})).unwrap();
|
||||
assert!(few["pieces"].as_array().unwrap().len() < many["pieces"].as_array().unwrap().len());
|
||||
// determinism
|
||||
let again = s.execute("clip.remix.properties", json!({"segments": 100.0})).unwrap();
|
||||
assert_eq!(again["pieces"], many["pieces"]);
|
||||
// undo / redo
|
||||
let cur = item(&s, c);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(item(&s, c), cur);
|
||||
// revert restores the clip exactly
|
||||
s.execute("clip.remix.revert", json!({})).unwrap();
|
||||
assert_eq!(item(&s, c), before);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(item(&s, c), cur);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_audio_is_the_source_pieces() {
|
||||
let (m, a) = music();
|
||||
let (mut s, c) = session(&m, None);
|
||||
let r = s.execute("clip.remix", json!({"seconds": 18.0})).unwrap();
|
||||
assert_cuts_on_beats(&r, &a);
|
||||
let it = item(&s, c);
|
||||
let rm = filmcraft_render::remix::Remix::of(&it).unwrap();
|
||||
let plan = rm.plan_at(SR);
|
||||
let total = it.duration.to_units_floor(SR as i64) as usize;
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
let q = s.active_sequence().unwrap();
|
||||
let mix = filmcraft_render::audio::mix_sequence(&s.project, q, 0, total, &provider);
|
||||
let mut d = 0usize;
|
||||
let mut checked = 0;
|
||||
let mut worst = 0f32;
|
||||
for p in &plan.pieces {
|
||||
for k in (plan.xfade as usize..p.len as usize - plan.xfade as usize).step_by(31) {
|
||||
let want = m[0][p.src as usize + k];
|
||||
worst = worst.max((mix.channels[0][d + k] - want).abs());
|
||||
checked += 1;
|
||||
}
|
||||
d += p.len as usize;
|
||||
}
|
||||
assert!(worst < 1e-5, "mix differs from the pieces by {worst}");
|
||||
assert!(checked > 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlaps_are_refused_and_noise_cannot_be_remixed() {
|
||||
let (m, _) = music();
|
||||
let (mut s, c) = session(&m, Some(40.0));
|
||||
let err = s.execute("clip.remix", json!({"seconds": 50.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("room"), "{err}");
|
||||
assert!(s.execute("clip.remix", json!({"seconds": 39.0})).is_ok());
|
||||
let _ = c;
|
||||
// a steady tone has no beat
|
||||
let tone: Vec<f32> = (0..SR as usize * 20).map(|i| (i as f32 * 0.05).sin() * 0.3).collect();
|
||||
let (mut s, _) = session(&[tone.clone(), tone], None);
|
||||
let err = s.execute("clip.remix.enable", json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("beat") || err.contains("short"), "{err}");
|
||||
}
|
||||
|
|
@ -712,7 +712,7 @@ static CATEGORIES: &[Category] = &[
|
|||
title: "Audio",
|
||||
rows: &[
|
||||
f("audio.automatchTime", "Automatch Time", float(0.0, 30.0, 3, "seconds"), true),
|
||||
f("audio.mixdownType", "5.1 Mixdown Type", Kind::Choice(MIXDOWN), false),
|
||||
f("audio.mixdownType", "5.1 Mixdown Type", Kind::Choice(MIXDOWN), true),
|
||||
f("audio.largeVolumeAdjustment", "Large Volume Adjustment", float(0.0, 96.0, 0, "dB"), true),
|
||||
b("audio.sumToMonoInSource", "Sum multichannel outputs to mono in Source Monitor", false),
|
||||
b("audio.scrubAudio", "Play audio while scrubbing in Source and Program Monitors", false),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,492 @@
|
|||
//! Voice-over recording (the timeline track header's Voice-over Record button).
|
||||
//!
|
||||
//! Recording follows Premiere's model: a *record point* R (the playhead, or the sequence In point
|
||||
//! when In/Out are set: punch-in / punch-out), playback starts a pre-roll before it at the
|
||||
//! *capture start* C = max(0, R − pre-roll), the input is captured from C, and when recording stops
|
||||
//! (or playback reaches the Out point) the audio from R on is written to a WAV file, imported and
|
||||
//! placed on the record track at R as one undoable step. The pre-roll audio is not kept.
|
||||
//!
|
||||
//! | command | does |
|
||||
//! |---|---|
|
||||
//! | `audio.voiceover.settings` | get / set the Voice-Over Record Settings (preferences), list input devices |
|
||||
//! | `audio.voiceover.start` | arm and start capturing; returns R, C, the punch-out and the countdown cue times |
|
||||
//! | `audio.voiceover.sync` | the host's playback actually started at `time`: restart the capture there |
|
||||
//! | `audio.voiceover.stop` | stop capturing; write, import and place the recording (one undo step) |
|
||||
//!
|
||||
//! Input goes through the [`AudioInput`] trait. The desktop app installs a cpal input; headless
|
||||
//! sessions (CLI, MCP, tests) use [`SyntheticInput`], a deterministic generated signal that produces
|
||||
//! exactly as many samples as the timeline asks for, so a recording lands sample-accurately.
|
||||
//!
|
||||
//! Files are named `<Name> <n>.wav` (Voice-over Record Settings ▸ Name, default `Voice-over`, `n`
|
||||
//! the first number not used by a project item or an existing file) and saved in Project Settings ▸
|
||||
//! Scratch Disks ▸ Captured Audio and Video, else next to the project file, else (unsaved project)
|
||||
//! in `Voice-over Recordings` in the data directory or the system temporary directory. They are
|
||||
//! mono 32-bit float WAV at the input's sample rate.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use filmcraft_project::{Sequence, TrackId, TrackKind};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
|
||||
use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
// ------------------------------------------------------------------------------------- input
|
||||
|
||||
/// What an input delivers once started.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct InputFormat {
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
/// An audio input device (cpal on desktop, [`SyntheticInput`] headless).
|
||||
pub trait AudioInput: Send {
|
||||
/// Names of the devices that can be chosen as Source.
|
||||
fn devices(&self) -> Vec<String>;
|
||||
/// Input channels of `device` ("" = the default device).
|
||||
fn channels(&self, device: &str) -> u16;
|
||||
/// Start capturing from `device` ("" = default), preferably at `sample_rate`.
|
||||
fn start(&mut self, device: &str, sample_rate: u32) -> std::result::Result<InputFormat, String>;
|
||||
/// 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>>;
|
||||
/// Drop everything captured so far: the next sample read is "now".
|
||||
fn discard(&mut self);
|
||||
fn stop(&mut self);
|
||||
}
|
||||
|
||||
/// The signal a [`SyntheticInput`] produces.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Synthetic {
|
||||
/// Channel 0: a 0.5 impulse every `period` samples starting at sample 0; channel 1: a 0.25
|
||||
/// 440 Hz sine.
|
||||
Clicks { period: usize },
|
||||
/// These samples (planar), then silence.
|
||||
Buffer(Vec<Vec<f32>>),
|
||||
}
|
||||
|
||||
/// A deterministic input for headless sessions and tests.
|
||||
pub struct SyntheticInput {
|
||||
pub signal: Synthetic,
|
||||
rate: u32,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl SyntheticInput {
|
||||
pub const DEVICE: &'static str = "Synthetic Input";
|
||||
pub fn new(signal: Synthetic) -> Self {
|
||||
Self { signal, rate: 48_000, pos: 0 }
|
||||
}
|
||||
/// Clicks every quarter second at 48 kHz.
|
||||
pub fn clicks() -> Self {
|
||||
Self::new(Synthetic::Clicks { period: 12_000 })
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioInput for SyntheticInput {
|
||||
fn devices(&self) -> Vec<String> {
|
||||
vec![Self::DEVICE.to_string()]
|
||||
}
|
||||
fn channels(&self, _device: &str) -> u16 {
|
||||
match &self.signal {
|
||||
Synthetic::Clicks { .. } => 2,
|
||||
Synthetic::Buffer(b) => b.len().max(1) as u16,
|
||||
}
|
||||
}
|
||||
fn start(&mut self, device: &str, sample_rate: u32) -> std::result::Result<InputFormat, String> {
|
||||
self.rate = sample_rate.max(1);
|
||||
self.pos = 0;
|
||||
Ok(InputFormat { sample_rate: self.rate, channels: self.channels(device) })
|
||||
}
|
||||
fn read(&mut self, frames: usize) -> Vec<Vec<f32>> {
|
||||
let p0 = self.pos;
|
||||
self.pos += frames;
|
||||
match &self.signal {
|
||||
Synthetic::Clicks { period } => {
|
||||
let period = (*period).max(1);
|
||||
let w = 2.0 * std::f64::consts::PI * 440.0 / self.rate as f64;
|
||||
let c0 = (p0..p0 + frames).map(|i| if i % period == 0 { 0.5 } else { 0.0 }).collect();
|
||||
let c1 = (p0..p0 + frames).map(|i| (0.25 * (w * i as f64).sin()) as f32).collect();
|
||||
vec![c0, c1]
|
||||
}
|
||||
Synthetic::Buffer(b) => b.iter().map(|c| (p0..p0 + frames).map(|i| c.get(i).copied().unwrap_or(0.0)).collect()).collect(),
|
||||
}
|
||||
}
|
||||
fn discard(&mut self) {
|
||||
self.pos = 0;
|
||||
}
|
||||
fn stop(&mut self) {}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- settings
|
||||
|
||||
/// Voice-Over Record Settings (persisted in the preferences as `voiceOver`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct VoiceOverPrefs {
|
||||
/// Source device ("" = Settings ▸ Audio Hardware ▸ Default Input, else the system default).
|
||||
pub source: String,
|
||||
/// Input channel recorded (0-based; the file is mono).
|
||||
pub input_channel: u32,
|
||||
/// Base name of the recorded files and clips (`<name> <n>`).
|
||||
pub name: String,
|
||||
/// Beep once a second during the pre-roll and at the record point.
|
||||
pub countdown_sound_cues: bool,
|
||||
/// Playback starts this long before the record point (seconds).
|
||||
pub preroll_seconds: f64,
|
||||
/// Playback continues this long after the punch-out (Out) point (seconds).
|
||||
pub postroll_seconds: f64,
|
||||
}
|
||||
|
||||
impl Default for VoiceOverPrefs {
|
||||
fn default() -> Self {
|
||||
Self { source: String::new(), input_channel: 0, name: "Voice-over".into(), countdown_sound_cues: true, preroll_seconds: 2.0, postroll_seconds: 2.0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- state
|
||||
|
||||
/// A recording in progress.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Recording {
|
||||
pub seq: filmcraft_project::ItemId,
|
||||
pub track: TrackId,
|
||||
/// Record point (sequence time): where the clip starts.
|
||||
pub record_start: Tick,
|
||||
/// Timeline time of the first captured sample.
|
||||
pub capture_start: Tick,
|
||||
/// Out point when punching in (recording ends there).
|
||||
pub punch_out: Option<Tick>,
|
||||
pub format: InputFormat,
|
||||
pub channel: u32,
|
||||
}
|
||||
|
||||
/// Voice-over state of a session: the input device and the recording in progress.
|
||||
#[derive(Default)]
|
||||
pub struct VoiceOver {
|
||||
/// Host input (None = a [`SyntheticInput`] is created on first use).
|
||||
pub input: Option<Box<dyn AudioInput>>,
|
||||
pub rec: Option<Recording>,
|
||||
/// Frames already read from the input in this recording (captured before stop).
|
||||
captured: Vec<f32>,
|
||||
}
|
||||
|
||||
impl VoiceOver {
|
||||
pub fn recording(&self) -> bool {
|
||||
self.rec.is_some()
|
||||
}
|
||||
fn input(&mut self) -> &mut Box<dyn AudioInput> {
|
||||
self.input.get_or_insert_with(|| Box::new(SyntheticInput::clicks()))
|
||||
}
|
||||
}
|
||||
|
||||
/// One countdown beep (1 kHz, 100 ms, −12 dBFS with 5 ms fades) at `sr`.
|
||||
pub fn cue_tone(sr: u32) -> Vec<f32> {
|
||||
let n = (sr as usize) / 10;
|
||||
let f = (sr as usize / 200).max(1);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let env = (i.min(n - 1 - i) as f32 / f as f32).min(1.0);
|
||||
0.25 * env * (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr as f32).sin()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Countdown cue times: one per whole second of pre-roll before the record point, and the record
|
||||
/// point itself.
|
||||
pub fn cue_times(capture_start: Tick, record_start: Tick) -> Vec<Tick> {
|
||||
let sec = Tick::from_seconds_f64(1.0);
|
||||
let mut v = Vec::new();
|
||||
let mut t = record_start;
|
||||
while t > capture_start {
|
||||
t -= sec;
|
||||
if t >= capture_start {
|
||||
v.push(t);
|
||||
}
|
||||
}
|
||||
v.reverse();
|
||||
v.push(record_start);
|
||||
v
|
||||
}
|
||||
|
||||
/// Mono 32-bit float WAV.
|
||||
pub fn write_wav_f32(samples: &[f32], rate: u32) -> Vec<u8> {
|
||||
let data_len = samples.len() * 4;
|
||||
let mut v = Vec::with_capacity(44 + data_len);
|
||||
v.extend_from_slice(b"RIFF");
|
||||
v.extend_from_slice(&((36 + data_len) as u32).to_le_bytes());
|
||||
v.extend_from_slice(b"WAVEfmt ");
|
||||
v.extend_from_slice(&16u32.to_le_bytes());
|
||||
v.extend_from_slice(&3u16.to_le_bytes());
|
||||
v.extend_from_slice(&1u16.to_le_bytes());
|
||||
v.extend_from_slice(&rate.to_le_bytes());
|
||||
v.extend_from_slice(&(rate * 4).to_le_bytes());
|
||||
v.extend_from_slice(&4u16.to_le_bytes());
|
||||
v.extend_from_slice(&32u16.to_le_bytes());
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&(data_len as u32).to_le_bytes());
|
||||
for s in samples {
|
||||
v.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- commands
|
||||
|
||||
fn audio_track_ref(seq: &Sequence, v: &Value) -> Option<TrackId> {
|
||||
let id = crate::mixer::strip_ref(seq, v)?;
|
||||
seq.audio_tracks.iter().any(|t| t.id == id).then_some(id)
|
||||
}
|
||||
|
||||
/// The track a recording goes to: `track`, else the record-armed audio track, else the first
|
||||
/// targeted audio track, else A1.
|
||||
pub fn record_track(s: &Session, p: &Value) -> Result<TrackId> {
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if let Some(v) = p.get("track") {
|
||||
return audio_track_ref(seq, v).ok_or_else(|| bad("audio.voiceover.start", format!("no audio track {v}")));
|
||||
}
|
||||
if let Some(t) = seq.audio_tracks.iter().find(|t| t.mixer.record_arm) {
|
||||
return Ok(t.id);
|
||||
}
|
||||
let tg = s.targeting().targeted;
|
||||
seq.audio_tracks
|
||||
.iter()
|
||||
.find(|t| tg.contains(&t.id))
|
||||
.or(seq.audio_tracks.first())
|
||||
.map(|t| t.id)
|
||||
.ok_or_else(|| bad("audio.voiceover.start", "the sequence has no audio track"))
|
||||
}
|
||||
|
||||
fn can_start(s: &Session) -> std::result::Result<(), String> {
|
||||
has_seq(s)?;
|
||||
if s.voiceover.recording() {
|
||||
return Err("a voice-over is already recording".into());
|
||||
}
|
||||
if s.active_sequence().is_some_and(|q| q.audio_tracks.is_empty()) {
|
||||
return Err("the sequence has no audio track".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_recording(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.voiceover.recording() { Ok(()) } else { Err("no voice-over is recording".into()) }
|
||||
}
|
||||
|
||||
fn settings_json(s: &mut Session) -> Value {
|
||||
let vo = s.prefs.voice_over.clone();
|
||||
let source = if vo.source.is_empty() { s.prefs.audio_hardware.default_input.clone() } else { vo.source.clone() };
|
||||
let input = s.voiceover.input();
|
||||
let devices = input.devices();
|
||||
let channels = input.channels(&source);
|
||||
json!({
|
||||
"settings": vo,
|
||||
"devices": devices,
|
||||
"channels": channels,
|
||||
"recording": s.voiceover.rec,
|
||||
})
|
||||
}
|
||||
|
||||
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut vo = s.prefs.voice_over.clone();
|
||||
if let Some(v) = str_p(p, "source") {
|
||||
vo.source = v.to_string();
|
||||
}
|
||||
if let Some(v) = u64_p(p, "inputChannel") {
|
||||
vo.input_channel = v.min(63) as u32;
|
||||
}
|
||||
if let Some(v) = str_p(p, "name") {
|
||||
let v = v.trim();
|
||||
if v.is_empty() {
|
||||
return Err(bad("audio.voiceover.settings", "the name can't be empty"));
|
||||
}
|
||||
vo.name = v.to_string();
|
||||
}
|
||||
if let Some(v) = bool_p(p, "countdownSoundCues") {
|
||||
vo.countdown_sound_cues = v;
|
||||
}
|
||||
if let Some(v) = f64_p(p, "prerollSeconds") {
|
||||
vo.preroll_seconds = v.clamp(0.0, 60.0);
|
||||
}
|
||||
if let Some(v) = f64_p(p, "postrollSeconds") {
|
||||
vo.postroll_seconds = v.clamp(0.0, 60.0);
|
||||
}
|
||||
if vo != s.prefs.voice_over {
|
||||
let mut prefs = s.prefs.clone();
|
||||
prefs.voice_over = vo;
|
||||
s.set_prefs(prefs).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
|
||||
}
|
||||
Ok(settings_json(s))
|
||||
}
|
||||
|
||||
fn start(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let track = record_track(s, p)?;
|
||||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||||
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||||
if seq.audio_tracks.iter().any(|t| t.id == track && t.locked) {
|
||||
return Err(bad("audio.voiceover.start", "the record track is locked"));
|
||||
}
|
||||
let sr = seq.settings.sample_rate.max(1);
|
||||
let (mark_in, mark_out) = (seq.mark_in, seq.mark_out);
|
||||
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));
|
||||
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.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)?;
|
||||
Ok(json!({
|
||||
"recording": true,
|
||||
"track": crate::mixer::strip_label(seq, track),
|
||||
"recordStart": record_start.0,
|
||||
"captureStart": capture_start.0,
|
||||
"punchOut": punch_out.map(|t| t.0),
|
||||
"postroll": Tick::from_seconds_f64(vo.postroll_seconds).0,
|
||||
"cues": cues,
|
||||
"sampleRate": format.sample_rate,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Playback really started at `time`: drop what was captured so far and count from there.
|
||||
fn sync(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||||
s.voiceover.input().discard();
|
||||
s.voiceover.captured.clear();
|
||||
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);
|
||||
Ok(json!({"captureStart": rec.capture_start.0}))
|
||||
}
|
||||
|
||||
/// Directory recordings are written to.
|
||||
fn record_dir(s: &Session, p: &Value) -> String {
|
||||
if let Some(d) = str_p(p, "dir") {
|
||||
return d.to_string();
|
||||
}
|
||||
if let Some(d) = s.project.settings.scratch.captured.clone().filter(|d| !d.is_empty()) {
|
||||
return d;
|
||||
}
|
||||
if let Some(d) = s.path.as_deref().and_then(|x| std::path::Path::new(x).parent()).filter(|d| !d.as_os_str().is_empty()) {
|
||||
return d.to_string_lossy().into_owned();
|
||||
}
|
||||
let base = s.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| d.to_path_buf()).unwrap_or_else(|| std::env::temp_dir().join("FilmCraft"));
|
||||
base.join("Voice-over Recordings").to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
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) {
|
||||
return Ok(json!({"recording": false, "placed": false}));
|
||||
}
|
||||
// write the file
|
||||
let dir = record_dir(s, p);
|
||||
let base = s.prefs.voice_over.name.clone();
|
||||
let names: std::collections::HashSet<String> = s.project.items.values().map(|i| i.name.clone()).collect();
|
||||
let mut k = 1;
|
||||
let path = loop {
|
||||
let file = format!("{base} {k}.wav");
|
||||
let path = std::path::Path::new(&dir).join(&file).to_string_lossy().into_owned();
|
||||
if !names.contains(&file) && s.services.file_size(&path).is_err() {
|
||||
break path;
|
||||
}
|
||||
k += 1;
|
||||
};
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
}
|
||||
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 at = rec.record_start;
|
||||
let track = rec.track;
|
||||
let rate = s.project.sequence(rec.seq).map(|q| q.settings.frame_rate).unwrap_or_default();
|
||||
let clip = s.edit("Record Voice-over", |pr, _| {
|
||||
let mut it = pr
|
||||
.make_track_item(item, TrackKind::Audio, at, TimeRange::new(Tick::ZERO, dur), rate)
|
||||
.ok_or(EngineError::Other("recording not importable".into()))?;
|
||||
it.duration = dur;
|
||||
let id = it.id;
|
||||
let mut next = pr.next_id;
|
||||
let seq = pr.sequence_mut(rec.seq).ok_or(EngineError::NoSequence)?;
|
||||
if !seq.audio_tracks.iter().any(|t| t.id == track) {
|
||||
return Err(bad("audio.voiceover.stop", "the record track was deleted"));
|
||||
}
|
||||
let durations = |_: filmcraft_project::ItemId| -> Option<Tick> { None };
|
||||
let mut ctx = filmcraft_edit::EditCtx { next_id: &mut next, media_duration: &durations, min_duration: rate.frame_duration() };
|
||||
filmcraft_edit::overwrite(seq, vec![(track, it)], &mut ctx)?;
|
||||
seq.check().map_err(EngineError::Other)?;
|
||||
pr.next_id = next;
|
||||
Ok(id)
|
||||
});
|
||||
crate::clip_ops::collapse_history(s, n0, "Record Voice-over");
|
||||
let clip = clip?;
|
||||
let seq = s.project.sequence(rec.seq).ok_or(EngineError::NoSequence)?;
|
||||
Ok(json!({
|
||||
"recording": false,
|
||||
"placed": true,
|
||||
"path": path,
|
||||
"item": item.0,
|
||||
"clip": clip.0,
|
||||
"track": crate::mixer::strip_label(seq, track),
|
||||
"start": at.0,
|
||||
"duration": dur.0,
|
||||
"samples": samples.len(),
|
||||
"sampleRate": sr,
|
||||
}))
|
||||
}
|
||||
|
||||
fn spec(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
params: &'static str,
|
||||
enabled: fn(&Session) -> std::result::Result<(), String>,
|
||||
run: fn(&mut Session, &Value) -> Result<Value>,
|
||||
journal: bool,
|
||||
) -> CommandSpec {
|
||||
CommandSpec { id, label, menu: &[], shortcut: None, params, enabled, run, journal }
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec(
|
||||
"audio.voiceover.settings",
|
||||
"Voice-Over Record Settings",
|
||||
r#"{"source":str?,"inputChannel":n?,"name":str?,"countdownSoundCues":bool?,"prerollSeconds":f64?,"postrollSeconds":f64?}"#,
|
||||
crate::commands::always,
|
||||
settings,
|
||||
true,
|
||||
),
|
||||
spec("audio.voiceover.start", "Start Voice-over Recording", r#"{"track":"A1"|id?,"time":ticks?,"preroll":seconds?}"#, can_start, start, true),
|
||||
spec("audio.voiceover.sync", "Sync Voice-over Capture", r#"{"time":ticks?}"#, is_recording, sync, true),
|
||||
spec("audio.voiceover.stop", "Stop Voice-over Recording", r#"{"time":ticks?,"dir":str?,"discard":bool?}"#, is_recording, stop, true),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
//! Tests of [`crate::voiceover`]: recording with the synthetic input lands sample-accurately.
|
||||
|
||||
use super::*;
|
||||
use crate::voiceover::{Synthetic, SyntheticInput, cue_times};
|
||||
use filmcraft_time::TICKS_PER_SECOND;
|
||||
use serde_json::json;
|
||||
|
||||
fn sec(x: f64) -> i64 {
|
||||
(x * TICKS_PER_SECOND as f64) as i64
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("filmcraft-vo-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The demo project plus an empty audio track (the record track, returned as `"A<n>"` with its
|
||||
/// index); every other audio track is muted so only the recording is heard.
|
||||
fn demo() -> (Session, String, usize) {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s.execute("sequence.addTracks", json!({"video": 0, "audio": 1})).unwrap();
|
||||
let n = s.active_sequence().unwrap().audio_tracks.len();
|
||||
for i in 1..n {
|
||||
s.execute("mixer.setStrip", json!({"strip": format!("A{i}"), "muted": true})).unwrap();
|
||||
}
|
||||
(s, format!("A{n}"), n - 1)
|
||||
}
|
||||
|
||||
fn mix(s: &Session, start: i64, n: usize) -> Vec<Vec<f32>> {
|
||||
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
||||
filmcraft_render::audio::mix_sequence(&s.project, s.active_sequence().unwrap(), start, n, &provider).channels
|
||||
}
|
||||
|
||||
fn track_clips(s: &Session, i: usize) -> Vec<filmcraft_project::TrackItem> {
|
||||
s.active_sequence().unwrap().audio_tracks[i].items.clone()
|
||||
}
|
||||
|
||||
#[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"] {
|
||||
let c = commands::find(id).unwrap_or_else(|| panic!("{id} missing"));
|
||||
assert!(!c.label.is_empty() && !c.params.is_empty());
|
||||
}
|
||||
let s = Session::default();
|
||||
assert!(!s.is_enabled("audio.voiceover.start"), "needs a sequence");
|
||||
assert!(!s.is_enabled("audio.voiceover.stop"), "nothing recording");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_persist_in_preferences() {
|
||||
let mut s = Session::default();
|
||||
let v = s.execute("audio.voiceover.settings", json!({})).unwrap();
|
||||
assert_eq!(v["settings"]["name"], "Voice-over");
|
||||
assert_eq!(v["devices"][0], SyntheticInput::DEVICE);
|
||||
assert_eq!(v["channels"], 2);
|
||||
s.execute(
|
||||
"audio.voiceover.settings",
|
||||
json!({"name": "Narration", "inputChannel": 1, "countdownSoundCues": false, "prerollSeconds": 3.0, "postrollSeconds": 99.0}),
|
||||
)
|
||||
.unwrap();
|
||||
let vo = &s.prefs.voice_over;
|
||||
assert_eq!((vo.name.as_str(), vo.input_channel, vo.countdown_sound_cues, vo.preroll_seconds, vo.postroll_seconds), ("Narration", 1, false, 3.0, 60.0));
|
||||
assert!(s.execute("audio.voiceover.settings", json!({"name": " "})).is_err());
|
||||
// round trip through the preferences JSON
|
||||
let j = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(j["voiceOver"]["name"], "Narration");
|
||||
let back: crate::autosave::Preferences = serde_json::from_value(j).unwrap();
|
||||
assert_eq!(back.voice_over, s.prefs.voice_over);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_lands_sample_accurately_after_the_preroll() {
|
||||
let (mut s, rt, ti) = demo();
|
||||
let dir = tmp("accurate");
|
||||
s.execute("audio.voiceover.settings", json!({"prerollSeconds": 1.0})).unwrap();
|
||||
s.voiceover.input = Some(Box::new(SyntheticInput::clicks()));
|
||||
let r = s.execute("audio.voiceover.start", json!({"track": rt, "time": sec(2.0)})).unwrap();
|
||||
assert_eq!(r["recordStart"], sec(2.0));
|
||||
assert_eq!(r["captureStart"], sec(1.0));
|
||||
assert_eq!(r["track"], rt.as_str());
|
||||
assert_eq!(r["cues"], json!([sec(1.0), sec(2.0)]));
|
||||
assert!(!s.is_enabled("audio.voiceover.start"), "already recording");
|
||||
let n_undo = s.history.undo.len();
|
||||
let v = s.execute("audio.voiceover.stop", json!({"time": sec(3.5), "dir": dir})).unwrap();
|
||||
assert_eq!(v["samples"], 72_000, "1.5 s at 48 kHz: the pre-roll is not kept");
|
||||
assert_eq!(s.history.undo.len(), n_undo + 1, "import + placement = one undo step");
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Record Voice-over");
|
||||
let path = v["path"].as_str().unwrap();
|
||||
assert!(path.ends_with("Voice-over 1.wav"), "{path}");
|
||||
assert!(std::path::Path::new(path).exists());
|
||||
let clips = track_clips(&s, ti);
|
||||
let c = clips.iter().find(|c| c.id.0 == v["clip"].as_u64().unwrap()).unwrap();
|
||||
assert_eq!((c.start, c.duration, c.source_in), (Tick(sec(2.0)), Tick(sec(1.5)), Tick::ZERO));
|
||||
// clicks every 12 000 capture samples from the capture start (1 s = sample 48 000): in the mix
|
||||
// at 96 000 + k·12 000, nothing in between
|
||||
let m = mix(&s, 90_000, 84_000);
|
||||
for (i, &x) in m[0].iter().enumerate() {
|
||||
let t = 90_000 + i as i64;
|
||||
let want = if (96_000..168_000).contains(&t) && (t - 48_000) % 12_000 == 0 { 0.5 } else { 0.0 };
|
||||
assert!((x - want).abs() < 1e-6, "sample {t}: {x} vs {want}");
|
||||
}
|
||||
// undo / redo
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(track_clips(&s, ti).is_empty());
|
||||
assert!(s.project.items.values().all(|i| i.name != "Voice-over 1.wav"));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(track_clips(&s, ti).iter().any(|c| c.start == Tick(sec(2.0)) && c.duration == Tick(sec(1.5))));
|
||||
// the next take gets the next number
|
||||
s.execute("audio.voiceover.start", json!({"track": rt, "time": sec(5.0)})).unwrap();
|
||||
let v2 = s.execute("audio.voiceover.stop", json!({"time": sec(6.0), "dir": dir})).unwrap();
|
||||
assert!(v2["path"].as_str().unwrap().ends_with("Voice-over 2.wav"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punch_in_records_between_in_and_out_only() {
|
||||
let (mut s, rt, ti) = demo();
|
||||
let dir = tmp("punch");
|
||||
s.execute("markers.markIn", json!({"time": sec(1.0)})).unwrap();
|
||||
s.execute("markers.markOut", json!({"time": sec(2.0)})).unwrap();
|
||||
// a ramp so every sample is identifiable: value = capture sample / 1e6
|
||||
let ramp: Vec<f32> = (0..400_000).map(|i| i as f32 * 1e-6).collect();
|
||||
s.voiceover.input = Some(Box::new(SyntheticInput::new(Synthetic::Buffer(vec![ramp]))));
|
||||
let r = s.execute("audio.voiceover.start", json!({"track": rt, "time": sec(7.0), "preroll": 0.5})).unwrap();
|
||||
assert_eq!(r["recordStart"], sec(1.0), "punch-in at the In point");
|
||||
assert_eq!(r["punchOut"], sec(2.0));
|
||||
// playback ran on past the Out point (post-roll): the clip still ends at Out
|
||||
let v = s.execute("audio.voiceover.stop", json!({"time": sec(4.0), "dir": dir})).unwrap();
|
||||
assert_eq!(v["samples"], 48_000);
|
||||
let c = track_clips(&s, ti).into_iter().find(|c| c.id.0 == v["clip"].as_u64().unwrap()).unwrap();
|
||||
assert_eq!((c.start, c.end()), (Tick(sec(1.0)), Tick(sec(2.0))));
|
||||
// clip sample 0 = capture sample 24 000 (0.5 s pre-roll)
|
||||
let m = mix(&s, 48_000, 48_000);
|
||||
for (i, &x) in m[0].iter().enumerate().step_by(997) {
|
||||
assert!((x - (24_000 + i) as f32 * 1e-6).abs() < 1e-6, "sample {i}: {x}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_track_choice_sync_and_discard() {
|
||||
let (mut s, rt, ti) = demo();
|
||||
let dir = tmp("track");
|
||||
// the record-armed track wins over targeting
|
||||
s.execute("mixer.setStrip", json!({"strip": rt, "recordArm": true})).unwrap();
|
||||
let r = s.execute("audio.voiceover.start", json!({"time": sec(1.0), "preroll": 0.0})).unwrap();
|
||||
assert_eq!(r["track"], rt.as_str());
|
||||
assert_eq!(r["cues"], json!([sec(1.0)]));
|
||||
// the host's playback started late: the capture restarts there
|
||||
let y = s.execute("audio.voiceover.sync", json!({"time": sec(1.0)})).unwrap();
|
||||
assert_eq!(y["captureStart"], sec(1.0));
|
||||
let v = s.execute("audio.voiceover.stop", json!({"time": sec(2.0), "dir": dir, "discard": true})).unwrap();
|
||||
assert_eq!(v["placed"], false);
|
||||
assert!(!s.voiceover.recording());
|
||||
// stopping at (or before) the record point places nothing
|
||||
s.execute("audio.voiceover.start", json!({"time": sec(3.0)})).unwrap();
|
||||
let v = s.execute("audio.voiceover.stop", json!({"time": sec(3.0), "dir": dir})).unwrap();
|
||||
assert_eq!(v["placed"], false);
|
||||
// unknown and locked tracks
|
||||
assert!(s.execute("audio.voiceover.start", json!({"track": "A99"})).is_err());
|
||||
assert!(s.execute("audio.voiceover.start", json!({"track": "V1"})).is_err());
|
||||
let a2 = s.active_sequence().unwrap().audio_tracks[ti].id.0;
|
||||
s.execute("timeline.setTrack", json!({"track": a2, "locked": true})).unwrap();
|
||||
assert!(s.execute("audio.voiceover.start", json!({"track": rt})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cue_times_count_down_whole_seconds() {
|
||||
let t = |x: f64| Tick(sec(x));
|
||||
assert_eq!(cue_times(t(0.5), t(3.5)), vec![t(0.5), t(1.5), t(2.5), t(3.5)]);
|
||||
assert_eq!(cue_times(t(1.2), t(3.0)), vec![t(2.0), t(3.0)]);
|
||||
assert_eq!(cue_times(t(3.0), t(3.0)), vec![t(3.0)]);
|
||||
let tone = crate::voiceover::cue_tone(48_000);
|
||||
assert_eq!(tone.len(), 4_800);
|
||||
assert!(tone.iter().all(|x| x.abs() <= 0.25) && tone[0] == 0.0);
|
||||
}
|
||||
|
|
@ -90,22 +90,31 @@ impl AudioOut {
|
|||
}
|
||||
|
||||
/// Mix `n` samples from `pos`, folded to the output channels (silence past the range end).
|
||||
/// Stereo output of a 5.1 Mix is the ITU-R BS.775 downmix; 5.1 output of a stereo Mix places it
|
||||
/// on L and R; 5.1 is in L, R, C, LFE, Ls, Rs order.
|
||||
fn mix(&self, pos: i64, n: usize, sources: &dyn SourceProvider) -> Vec<Vec<f32>> {
|
||||
let real = (self.end - pos).clamp(0, n as i64) as usize;
|
||||
let mut st = if real > 0 { filmcraft_render::audio::mix_sequence(&self.project, &self.seq, pos, real, sources).channels } else { vec![Vec::new(); 2] };
|
||||
let out_ch = if self.channels == 6 { 6 } else { 2 };
|
||||
let mut st = if real > 0 {
|
||||
let layout = filmcraft_audio_dsp::channels::Layout::from_channels(out_ch);
|
||||
filmcraft_render::audio::mix_sequence_layout(
|
||||
&self.project,
|
||||
&self.seq,
|
||||
pos,
|
||||
real,
|
||||
sources,
|
||||
layout,
|
||||
filmcraft_audio_dsp::channels::Mixdown::FrontRear,
|
||||
)
|
||||
.channels
|
||||
} else {
|
||||
vec![Vec::new(); out_ch]
|
||||
};
|
||||
st.resize(out_ch, Vec::new());
|
||||
for c in st.iter_mut() {
|
||||
c.resize(n, 0.0);
|
||||
}
|
||||
if st.len() < 2 {
|
||||
let c0 = st.first().cloned().unwrap_or_else(|| vec![0.0; n]);
|
||||
st = vec![c0.clone(), c0];
|
||||
}
|
||||
if self.channels == 1 {
|
||||
vec![st[0].iter().zip(&st[1]).map(|(l, r)| 0.5 * (l + r)).collect()]
|
||||
} else {
|
||||
st.truncate(2);
|
||||
st
|
||||
}
|
||||
if self.channels == 1 { vec![st[0].iter().zip(&st[1]).map(|(l, r)| 0.5 * (l + r)).collect()] } else { st }
|
||||
}
|
||||
|
||||
/// Output samples up to `until` (exclusive), planar; None when there is nothing new.
|
||||
|
|
|
|||
|
|
@ -557,7 +557,12 @@ impl AudioEncoder for AacEncoder {
|
|||
SampleEntry::aac(self.enc.audio_specific_config(), self.channels, self.rate)
|
||||
}
|
||||
fn encode(&mut self, planar: &[Vec<f32>]) -> Result<Vec<Vec<u8>>> {
|
||||
let refs: Vec<&[f32]> = planar.iter().map(Vec::as_slice).collect();
|
||||
let refs: Vec<&[f32]> = if planar.len() == 6 {
|
||||
// ours: L, R, C, LFE, Ls, Rs → AAC channel configuration 6: C, L, R, Ls, Rs, LFE
|
||||
[2usize, 0, 1, 4, 5, 3].iter().map(|&c| planar[c].as_slice()).collect()
|
||||
} else {
|
||||
planar.iter().map(Vec::as_slice).collect()
|
||||
};
|
||||
Ok(self.enc.encode(&refs))
|
||||
}
|
||||
fn flush(&mut self) -> Result<Vec<Vec<u8>>> {
|
||||
|
|
@ -1074,3 +1079,6 @@ mod tests;
|
|||
|
||||
#[cfg(test)]
|
||||
mod settings_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod surround_tests;
|
||||
|
|
|
|||
|
|
@ -7,22 +7,34 @@ fn quantise(s: f32, bits: u16) -> i32 {
|
|||
(s.clamp(-1.0, 1.0) * max).round() as i32
|
||||
}
|
||||
|
||||
/// A RIFF/WAVE file of interleaved samples.
|
||||
/// A RIFF/WAVE file of interleaved samples. More than two channels are written as
|
||||
/// `WAVE_FORMAT_EXTENSIBLE` with the speaker mask of the layout (6 channels: L, R, C, LFE, Ls, Rs
|
||||
/// = `0x3F`).
|
||||
pub fn write_wav(interleaved: &[f32], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
|
||||
let bits: u16 = if bits >= 24 { 24 } else { 16 };
|
||||
let bps = (bits / 8) as u32;
|
||||
let data_len = interleaved.len() as u32 * bps;
|
||||
let mut v = Vec::with_capacity(44 + data_len as usize);
|
||||
let ext = channels > 2;
|
||||
let fmt_len: u32 = if ext { 40 } else { 16 };
|
||||
let mut v = Vec::with_capacity(28 + fmt_len as usize + data_len as usize);
|
||||
v.extend_from_slice(b"RIFF");
|
||||
v.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
v.extend_from_slice(&(20 + fmt_len + data_len).to_le_bytes());
|
||||
v.extend_from_slice(b"WAVEfmt ");
|
||||
v.extend_from_slice(&16u32.to_le_bytes());
|
||||
v.extend_from_slice(&1u16.to_le_bytes());
|
||||
v.extend_from_slice(&fmt_len.to_le_bytes());
|
||||
v.extend_from_slice(&(if ext { 0xFFFEu16 } else { 1 }).to_le_bytes());
|
||||
v.extend_from_slice(&channels.to_le_bytes());
|
||||
v.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
v.extend_from_slice(&(sample_rate * channels as u32 * bps).to_le_bytes());
|
||||
v.extend_from_slice(&(channels * bps as u16).to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
if ext {
|
||||
// cbSize, valid bits, channel mask, KSDATAFORMAT_SUBTYPE_PCM
|
||||
v.extend_from_slice(&22u16.to_le_bytes());
|
||||
v.extend_from_slice(&bits.to_le_bytes());
|
||||
let mask: u32 = if channels == 6 { 0x3F } else { 0 };
|
||||
v.extend_from_slice(&mask.to_le_bytes());
|
||||
v.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71]);
|
||||
}
|
||||
v.extend_from_slice(b"data");
|
||||
v.extend_from_slice(&data_len.to_le_bytes());
|
||||
for &s in interleaved {
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ pub struct AudioSettings {
|
|||
pub codec: AudioCodec,
|
||||
/// Output sample rate (None = the sequence's).
|
||||
pub sample_rate: Option<u32>,
|
||||
/// 1 (mono) or 2 (stereo).
|
||||
/// 1 (mono), 2 (stereo) or 6 (5.1: L, R, C, LFE, Ls, Rs).
|
||||
pub channels: u32,
|
||||
/// AAC bitrate.
|
||||
pub bitrate_kbps: u32,
|
||||
|
|
@ -334,7 +334,11 @@ impl ExportSettings {
|
|||
_ => self.max_bitrate_kbps.filter(|m| *m >= target).unwrap_or(target * 3 / 2),
|
||||
};
|
||||
let keyint = self.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (fps * 2.0).round().max(1.0) as u32);
|
||||
let channels = self.audio.channels.clamp(1, 2);
|
||||
let channels = match self.audio.channels {
|
||||
0 | 1 => 1,
|
||||
6.. => 6,
|
||||
_ => 2,
|
||||
};
|
||||
Resolved {
|
||||
width: w,
|
||||
height: h,
|
||||
|
|
@ -460,7 +464,11 @@ impl ExportSettings {
|
|||
v
|
||||
};
|
||||
let audio = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) {
|
||||
let ch = if r.channels == 1 { "Mono" } else { "Stereo" };
|
||||
let ch = match r.channels {
|
||||
1 => "Mono",
|
||||
6 => "5.1",
|
||||
_ => "Stereo",
|
||||
};
|
||||
match self.audio_codec() {
|
||||
AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate),
|
||||
_ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
//! 5.1 export: WAV (WAVE_FORMAT_EXTENSIBLE, 6 channels), ProRes MOV with 6 PCM channels (`chan`
|
||||
//! atom) and H.264 MP4 with AAC 5.1 (channel configuration 6). ffprobe checks the channel layout;
|
||||
//! ffmpeg decodes the PCM files sample-exactly and the AAC file per channel (each channel carries
|
||||
//! its own tone). ffmpeg / ffprobe are external test oracles only.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_frame::{AudioBuffer, VideoFrame};
|
||||
use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource};
|
||||
use filmcraft_project::{AudioChannels, ItemKind, Label, MediaClip, MediaRef, SequenceSettings, TrackKind};
|
||||
use filmcraft_render::SourceMap;
|
||||
use filmcraft_time::TICKS_PER_SECOND;
|
||||
|
||||
use super::*;
|
||||
|
||||
const SR: u32 = 48_000;
|
||||
/// Tone frequency of each 5.1 channel (L, R, C, LFE, Ls, Rs).
|
||||
const HZ: [f64; 6] = [500.0, 700.0, 900.0, 60.0, 1300.0, 1700.0];
|
||||
|
||||
/// A 6-channel source: channel c is a 0.25-amplitude sine at `HZ[c]`.
|
||||
struct Tones6 {
|
||||
info: MediaInfo,
|
||||
}
|
||||
|
||||
fn tone(c: usize, i: i64) -> f32 {
|
||||
(0.25 * (2.0 * std::f64::consts::PI * HZ[c] * i as f64 / SR as f64).sin()) as f32
|
||||
}
|
||||
|
||||
impl MediaSource for Tones6 {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.info
|
||||
}
|
||||
fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
|
||||
Err(MediaError::Unsupported("audio only".into()))
|
||||
}
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
Ok(AudioBuffer { sample_rate, channels: (0..6).map(|c| (0..frames).map(|i| tone(c, start + i as i64)).collect()).collect() })
|
||||
}
|
||||
}
|
||||
|
||||
/// A 320×180 sequence with a 5.1 Mix and one 5.1 audio track holding 1 s of the six tones.
|
||||
fn surround() -> (Arc<Project>, ItemId, SourceMap) {
|
||||
let mut p = Project::new("51");
|
||||
let info = MediaInfo {
|
||||
name: "tones51".into(),
|
||||
kind: MediaKind::AudioOnly,
|
||||
duration: Tick(2 * TICKS_PER_SECOND),
|
||||
video: None,
|
||||
audio: Some(AudioStreamInfo { sample_rate: SR, channels: 6, codec: "test".into(), bits_per_sample: Some(32) }),
|
||||
container: "test".into(),
|
||||
start_timecode: None,
|
||||
file_size: None,
|
||||
};
|
||||
let id = p.add_item(
|
||||
"tones51",
|
||||
Label::Iris,
|
||||
ItemKind::Media(MediaClip {
|
||||
media: MediaRef::File { path: "/tones51.wav".into() },
|
||||
info: info.clone(),
|
||||
interpret: Default::default(),
|
||||
mark_in: None,
|
||||
mark_out: None,
|
||||
markers: vec![],
|
||||
offline: false,
|
||||
proxy: None,
|
||||
identity: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let r = FrameRate::FPS_24;
|
||||
let mut st = SequenceSettings { width: 320, height: 180, frame_rate: r, ..Default::default() };
|
||||
st.audio_master = AudioChannels::Surround51;
|
||||
let seq = p.new_sequence("s", st, 1, 1, None);
|
||||
let ai = p.make_track_item(id, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(24)), r).unwrap();
|
||||
let q = p.sequence_mut(seq).unwrap();
|
||||
q.audio_tracks[0].channels = AudioChannels::Surround51;
|
||||
q.audio_tracks[0].items.push(ai);
|
||||
let mut m = SourceMap::default();
|
||||
m.0.insert(id, Arc::new(Tones6 { info }) as Arc<dyn MediaSource>);
|
||||
(Arc::new(p), seq, m)
|
||||
}
|
||||
|
||||
fn out_path(name: &str) -> String {
|
||||
let d = filmcraft_testkit::fixtures::workspace_root().join("target").join("export-tests").join(format!("surround-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name).to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn settings(format: Format, path: &str) -> ExportSettings {
|
||||
let mut s = ExportSettings { format, path: path.to_string(), ..Default::default() };
|
||||
s.audio.channels = 6;
|
||||
s.audio.bits = 24;
|
||||
s.audio.sample_rate = Some(SR);
|
||||
s
|
||||
}
|
||||
|
||||
/// ffprobe's (channels, channel_layout) of the first audio stream.
|
||||
fn probe(path: &str) -> Option<(u64, String)> {
|
||||
let ffprobe = filmcraft_testkit::ffprobe_or_skip("5.1 export")?;
|
||||
let out = std::process::Command::new(ffprobe).args(["-v", "error", "-of", "json", "-select_streams", "a:0", "-show_streams", path]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let j: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
let s = &j["streams"][0];
|
||||
Some((s["channels"].as_u64().unwrap_or(0), s["channel_layout"].as_str().unwrap_or("").to_string()))
|
||||
}
|
||||
|
||||
/// ffmpeg's decode of the first audio stream as planar f64 (from s32le: exact for 24-bit PCM).
|
||||
fn decode(path: &str) -> Option<Vec<Vec<f64>>> {
|
||||
let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("5.1 export")?;
|
||||
let out =
|
||||
std::process::Command::new(ffmpeg).args(["-v", "error", "-i", path, "-map", "0:a:0", "-f", "s32le", "-acodec", "pcm_s32le", "-"]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let ints: Vec<i32> = out.stdout.chunks_exact(4).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
let n = ints.len() / 6;
|
||||
Some((0..6).map(|c| (0..n).map(|i| ints[i * 6 + c] as f64).collect()).collect())
|
||||
}
|
||||
|
||||
/// Every decoded sample equals our 24-bit quantisation of the tone (sample-exact).
|
||||
fn assert_sample_exact(ch: &[Vec<f64>], frames: usize) {
|
||||
assert_eq!(ch.len(), 6);
|
||||
assert_eq!(ch[0].len(), frames, "frame count");
|
||||
for (c, v) in ch.iter().enumerate() {
|
||||
for (i, x) in v.iter().enumerate() {
|
||||
let want = ((tone(c, i as i64).clamp(-1.0, 1.0) * 8_388_607.0).round() as i64) << 8;
|
||||
assert_eq!(*x as i64, want, "channel {c} sample {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_51(layout: &str) -> bool {
|
||||
layout.starts_with("5.1")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_51_is_extensible_and_sample_exact() {
|
||||
let (p, seq, m) = surround();
|
||||
let path = out_path("s51.wav");
|
||||
export(&p, seq, &settings(Format::Wav, &path), &m, &Progress::default()).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 0xFFFE, "WAVE_FORMAT_EXTENSIBLE");
|
||||
assert_eq!(u16::from_le_bytes([bytes[22], bytes[23]]), 6);
|
||||
assert_eq!(u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]), 0x3F, "L R C LFE Ls Rs");
|
||||
if let Some((n, layout)) = probe(&path) {
|
||||
assert_eq!(n, 6);
|
||||
assert!(is_51(&layout), "{layout}");
|
||||
}
|
||||
if let Some(ch) = decode(&path) {
|
||||
assert_sample_exact(&ch, SR as usize);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_mov_carries_six_pcm_channels() {
|
||||
let (p, seq, m) = surround();
|
||||
let path = out_path("s51.mov");
|
||||
export(&p, seq, &settings(Format::ProRes, &path), &m, &Progress::default()).unwrap();
|
||||
if let Some((n, layout)) = probe(&path) {
|
||||
assert_eq!(n, 6);
|
||||
assert!(is_51(&layout), "{layout}");
|
||||
}
|
||||
if let Some(ch) = decode(&path) {
|
||||
assert_sample_exact(&ch, SR as usize);
|
||||
}
|
||||
}
|
||||
|
||||
/// Energy of `x` at `hz` (Goertzel), normalised by length.
|
||||
fn tone_power(x: &[f64], hz: f64) -> f64 {
|
||||
let w = 2.0 * std::f64::consts::PI * hz / SR as f64;
|
||||
let (mut s1, mut s2) = (0.0f64, 0.0f64);
|
||||
for v in x {
|
||||
let s0 = v + 2.0 * w.cos() * s1 - s2;
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
(s1 * s1 + s2 * s2 - 2.0 * w.cos() * s1 * s2) / (x.len() as f64).powi(2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aac_51_in_mp4_keeps_every_channel_in_place() {
|
||||
let (p, seq, m) = surround();
|
||||
let path = out_path("s51.mp4");
|
||||
let mut st = settings(Format::H264, &path);
|
||||
st.audio.bitrate_kbps = 384;
|
||||
export(&p, seq, &st, &m, &Progress::default()).unwrap();
|
||||
// our own demuxer sees an AAC track with channel configuration 6
|
||||
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
|
||||
let src = filmcraft_codecs::open_bytes("s51.mp4", bytes).unwrap();
|
||||
assert_eq!(src.info().audio.as_ref().unwrap().channels, 6);
|
||||
if let Some((n, layout)) = probe(&path) {
|
||||
assert_eq!(n, 6);
|
||||
assert!(is_51(&layout), "{layout}");
|
||||
}
|
||||
// ffmpeg decodes to L R C LFE Ls Rs: each channel's own tone dominates (≥ 30 dB above the
|
||||
// others' tones), so no channel was swapped by the AAC reordering
|
||||
if let Some(ch) = decode(&path) {
|
||||
for (c, v) in ch.iter().enumerate() {
|
||||
let mid = &v[v.len() / 4..v.len() * 3 / 4];
|
||||
let own = tone_power(mid, HZ[c]);
|
||||
for (k, hz) in HZ.iter().enumerate().filter(|(k, _)| *k != c) {
|
||||
let other = tone_power(mid, *hz);
|
||||
assert!(own > other * 1000.0, "channel {c}: own {own:e} vs tone {k} {other:e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_export_of_a_51_mix_is_the_bs775_downmix() {
|
||||
let (p, seq, m) = surround();
|
||||
let path = out_path("fold.wav");
|
||||
let mut st = settings(Format::Wav, &path);
|
||||
st.audio.channels = 2;
|
||||
export(&p, seq, &st, &m, &Progress::default()).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(u16::from_le_bytes([bytes[20], bytes[21]]), 1, "plain PCM for stereo");
|
||||
let k = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let at = |frame: usize, c: usize| {
|
||||
let o = 44 + (frame * 2 + c) * 3;
|
||||
(i32::from_le_bytes([0, bytes[o], bytes[o + 1], bytes[o + 2]]) >> 8) as f32 / 8_388_607.0
|
||||
};
|
||||
for i in [10usize, 1234, 30_000] {
|
||||
let l = tone(0, i as i64) + k * tone(2, i as i64) + k * tone(4, i as i64);
|
||||
let r = tone(1, i as i64) + k * tone(2, i as i64) + k * tone(5, i as i64);
|
||||
assert!((at(i, 0) - l).abs() < 2e-6 && (at(i, 1) - r).abs() < 2e-6, "frame {i}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1408,6 +1408,14 @@ impl SampleEntry {
|
|||
b.u8(p.bits as u8);
|
||||
b.end(m);
|
||||
}
|
||||
CodecConfig::Pcm(p) if p.channels == 6 => {
|
||||
// QuickTime channel layout: kAudioChannelLayoutTag_MPEG_5_1_A (L R C LFE Ls Rs)
|
||||
let m = b.start_full(b"chan", 0, 0);
|
||||
b.u32((121 << 16) | 6);
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.end(m);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(br) = self.bitrate {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
//! | `pan` | pan / balance, −100 … 100 | `MixerStrip::lanes` |
|
||||
//! | `mute` | 0 / 1 (hold) | `MixerStrip::lanes` |
|
||||
//! | `send.<i>.level` | send `i` level, dB | `MixerStrip::lanes` |
|
||||
//! | `pan51.x`, `pan51.y` | 5.1 panner puck, −100 (left / rear) … 100 (right / front) | `MixerStrip::lanes` (static value = the lane's value) |
|
||||
//! | `pan51.center` | 5.1 panner Center %, 0 … 100 | `MixerStrip::lanes` |
|
||||
//! | `pan51.lfe` | 5.1 panner LFE level, dB | `MixerStrip::lanes` |
|
||||
//! | `fx.<slot>.<param>` | insert effect parameter | the effect's own `Param` keyframes |
|
||||
//!
|
||||
//! [`AutomationMode`] decides whether playback reads the lanes (anything but Off) and how the
|
||||
|
|
@ -27,6 +30,29 @@ use crate::{Track, TrackId};
|
|||
pub const LANE_VOLUME: &str = "volume";
|
||||
pub const LANE_PAN: &str = "pan";
|
||||
pub const LANE_MUTE: &str = "mute";
|
||||
/// 5.1 panner puck, left (−100) … right (100). Used when the strip feeds a 5.1 submix or Mix.
|
||||
pub const LANE_PAN51_X: &str = "pan51.x";
|
||||
/// 5.1 panner puck, rear (−100) … front (100).
|
||||
pub const LANE_PAN51_Y: &str = "pan51.y";
|
||||
/// 5.1 panner Center % (0 … 100): the centre speaker's share of the front image.
|
||||
pub const LANE_PAN51_CENTER: &str = "pan51.center";
|
||||
/// 5.1 panner LFE level (dB).
|
||||
pub const LANE_PAN51_LFE: &str = "pan51.lfe";
|
||||
/// The 5.1 panner lanes.
|
||||
pub const PAN51_LANES: [&str; 4] = [LANE_PAN51_X, LANE_PAN51_Y, LANE_PAN51_CENTER, LANE_PAN51_LFE];
|
||||
|
||||
/// Default value of a 5.1 panner lane for a strip of channel format `channels`: puck at front
|
||||
/// centre, Center 100 %, LFE 0 dB for 5.1 strips (their LFE channel passes) and −∞ for mono /
|
||||
/// stereo strips (nothing is sent to the subwoofer unless asked for).
|
||||
pub fn pan51_default(key: &str, channels: crate::AudioChannels) -> Option<f64> {
|
||||
match key {
|
||||
LANE_PAN51_X => Some(0.0),
|
||||
LANE_PAN51_Y => Some(100.0),
|
||||
LANE_PAN51_CENTER => Some(100.0),
|
||||
LANE_PAN51_LFE => Some(if channels == crate::AudioChannels::Surround51 { 0.0 } else { FADER_MIN_DB }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many insert effects per strip (Premiere's effect slots 1–5).
|
||||
pub const MAX_INSERTS: usize = 5;
|
||||
|
|
@ -176,6 +202,9 @@ pub fn lane_info(key: &str) -> Option<LaneInfo> {
|
|||
LANE_VOLUME => Some(LaneInfo { min: FADER_MIN_DB, max: FADER_MAX_DB, hold: false, unit: "dB" }),
|
||||
LANE_PAN => Some(LaneInfo { min: -100.0, max: 100.0, hold: false, unit: "" }),
|
||||
LANE_MUTE => Some(LaneInfo { min: 0.0, max: 1.0, hold: true, unit: "" }),
|
||||
LANE_PAN51_X | LANE_PAN51_Y => Some(LaneInfo { min: -100.0, max: 100.0, hold: false, unit: "" }),
|
||||
LANE_PAN51_CENTER => Some(LaneInfo { min: 0.0, max: 100.0, hold: false, unit: "%" }),
|
||||
LANE_PAN51_LFE => Some(LaneInfo { min: FADER_MIN_DB, max: FADER_MAX_DB, hold: false, unit: "dB" }),
|
||||
k if parse_send_lane(k).is_some() => Some(LaneInfo { min: FADER_MIN_DB, max: FADER_MAX_DB, hold: false, unit: "dB" }),
|
||||
_ => None,
|
||||
}
|
||||
|
|
@ -198,6 +227,7 @@ impl Track {
|
|||
LANE_VOLUME => Some(self.volume_db),
|
||||
LANE_PAN => Some(self.pan),
|
||||
LANE_MUTE => Some(if self.muted { 1.0 } else { 0.0 }),
|
||||
k if PAN51_LANES.contains(&k) => self.mixer.lanes.get(k).and_then(|p| p.value.as_f64()).or_else(|| pan51_default(k, self.channels)),
|
||||
k => {
|
||||
if let Some(i) = parse_send_lane(k) {
|
||||
return self.mixer.sends.get(i).map(|s| s.level_db);
|
||||
|
|
@ -262,6 +292,11 @@ impl Track {
|
|||
LANE_VOLUME => self.volume_db = v.clamp(FADER_MIN_DB, FADER_MAX_DB),
|
||||
LANE_PAN => self.pan = v.clamp(-100.0, 100.0),
|
||||
LANE_MUTE => self.muted = v >= 0.5,
|
||||
k if PAN51_LANES.contains(&k) => {
|
||||
let i = lane_info(k).expect("pan51 lanes have info");
|
||||
let v = v.clamp(i.min, i.max);
|
||||
self.mixer.lanes.entry(k.to_string()).or_insert_with(|| Param::new(ParamValue::Float(v))).value = ParamValue::Float(v);
|
||||
}
|
||||
k => {
|
||||
if let Some(i) = parse_send_lane(k) {
|
||||
match self.mixer.sends.get_mut(i) {
|
||||
|
|
@ -360,6 +395,20 @@ impl crate::Sequence {
|
|||
pub fn mix_track_mut(&mut self, id: TrackId) -> Option<&mut Track> {
|
||||
self.audio_tracks.iter_mut().chain(self.submix_tracks.iter_mut()).find(|t| t.id == id)
|
||||
}
|
||||
/// Channel format of the bus strip `id` feeds (its output submix, or the Mix).
|
||||
pub fn output_channels(&self, id: TrackId) -> crate::AudioChannels {
|
||||
if id == MASTER_STRIP {
|
||||
return self.settings.audio_master;
|
||||
}
|
||||
match self.mix_track(id).and_then(|t| t.mixer.output).and_then(|o| self.submix_tracks.iter().find(|s| s.id == o)) {
|
||||
Some(s) => s.channels,
|
||||
None => self.settings.audio_master,
|
||||
}
|
||||
}
|
||||
/// Whether strip `id` pans with the 5.1 panner (it feeds a 5.1 submix or a 5.1 Mix).
|
||||
pub fn pans_51(&self, id: TrackId) -> bool {
|
||||
id != MASTER_STRIP && self.output_channels(id) == crate::AudioChannels::Surround51
|
||||
}
|
||||
/// Mix track volume (dB) at sequence time `t` (automation unless the Mix is in Off mode).
|
||||
pub fn master_volume_at(&self, t: Tick) -> f64 {
|
||||
match self.master_mixer.lanes.get(LANE_VOLUME) {
|
||||
|
|
@ -535,6 +584,26 @@ mod tests {
|
|||
assert!(t.lane(&send_lane(2)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan51_lanes_have_defaults_and_store_statics_in_lanes() {
|
||||
let mut t = tr();
|
||||
assert_eq!(t.lane_static(LANE_PAN51_Y), Some(100.0));
|
||||
assert_eq!(t.lane_static(LANE_PAN51_CENTER), Some(100.0));
|
||||
assert_eq!(t.lane_static(LANE_PAN51_LFE), Some(FADER_MIN_DB));
|
||||
t.channels = crate::AudioChannels::Surround51;
|
||||
assert_eq!(t.lane_static(LANE_PAN51_LFE), Some(0.0));
|
||||
assert!(t.set_lane_static(LANE_PAN51_X, -250.0));
|
||||
assert_eq!(t.lane_static(LANE_PAN51_X), Some(-100.0));
|
||||
assert!(t.automated_lanes().is_empty());
|
||||
let p = t.lane_mut(LANE_PAN51_X).unwrap();
|
||||
p.keyframes.push(Keyframe::new(Tick(0), ParamValue::Float(-100.0)));
|
||||
p.keyframes.push(Keyframe::new(Tick(100), ParamValue::Float(100.0)));
|
||||
assert_eq!(t.lane_value(LANE_PAN51_X, Tick(50)), 0.0);
|
||||
assert_eq!(t.automated_lanes(), vec![LANE_PAN51_X.to_string()]);
|
||||
let back: Track = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
|
||||
assert_eq!(back, t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults() {
|
||||
let s: MixerStrip = serde_json::from_str("{}").unwrap();
|
||||
|
|
|
|||
|
|
@ -1,30 +1,92 @@
|
|||
//! Clip-level audio: clip gain → clip effects → Volume / Channel Volume / Panner, audio transitions
|
||||
//! and speed changes (varispeed resampling), summed per track. The track/submix/Mix graph that
|
||||
//! consumes it is [`crate::mixer`].
|
||||
//!
|
||||
//! Stereo, mono and adaptive tracks carry two channels. 5.1 tracks carry six (L, R, C, LFE, Ls,
|
||||
//! Rs): their clips play the source's first six channels (or the six `source_channels` picked with
|
||||
//! Modify ▸ Audio Channels); mono and stereo sources on a 5.1 track are upmixed onto the front
|
||||
//! speakers ([`filmcraft_audio_dsp::channels`]). The clip Panner (stereo balance) does not apply to
|
||||
//! 5.1 clips, and Channel Volume's left/right levels apply to L and R.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use filmcraft_audio_dsp::channels::{self as chans, Layout, Mixdown};
|
||||
use filmcraft_frame::AudioBuffer;
|
||||
use filmcraft_project::{Project, Sequence, Track, TrackItem};
|
||||
use filmcraft_project::{AudioChannels, Project, Sequence, Track, TrackId, TrackItem};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
|
||||
use crate::mixer::Override;
|
||||
use crate::{SourceProvider, transitions};
|
||||
|
||||
/// Live Clip Mixer lanes ([`crate::mixer::LiveMix`] overrides keyed by the track id): the clip
|
||||
/// Volume level (dB) and Panner balance of the clip under the playhead while a Clip Mixer control
|
||||
/// is held.
|
||||
pub const CLIP_LANE_VOLUME: &str = "clip.volume";
|
||||
pub const CLIP_LANE_PAN: &str = "clip.pan";
|
||||
|
||||
pub fn db_to_gain(db: f64) -> f32 {
|
||||
if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 }
|
||||
}
|
||||
|
||||
/// Mix `frames` stereo samples of sequence audio starting at sample `start` (sequence rate), through
|
||||
/// the full mixer graph ([`crate::mixer`]). Export and playback both use this.
|
||||
/// the full mixer graph ([`crate::mixer`]). A 5.1 Mix is folded to stereo with the ITU-R BS.775
|
||||
/// downmix. Export and playback use [`mix_sequence_layout`] for other channel counts.
|
||||
pub fn mix_sequence(project: &Project, seq: &Sequence, start: i64, frames: usize, sources: &dyn SourceProvider) -> AudioBuffer {
|
||||
crate::mixer::mix_graph(project, seq, start, frames, sources, None)
|
||||
to_layout(crate::mixer::mix_graph(project, seq, start, frames, sources, None), Layout::Stereo, Mixdown::FrontRear)
|
||||
}
|
||||
|
||||
/// The sequence mix in `layout` (mono, stereo or 5.1): a 5.1 Mix is downmixed with `mixdown`
|
||||
/// (BS.775 for [`Mixdown::FrontRear`]), a stereo Mix is upmixed onto the front speakers.
|
||||
pub fn mix_sequence_layout(
|
||||
project: &Project,
|
||||
seq: &Sequence,
|
||||
start: i64,
|
||||
frames: usize,
|
||||
sources: &dyn SourceProvider,
|
||||
layout: Layout,
|
||||
mixdown: Mixdown,
|
||||
) -> AudioBuffer {
|
||||
to_layout(crate::mixer::mix_graph(project, seq, start, frames, sources, None), layout, mixdown)
|
||||
}
|
||||
|
||||
/// Convert a mixed buffer (2 or 6 channels) to `layout`.
|
||||
pub fn to_layout(b: AudioBuffer, layout: Layout, mixdown: Mixdown) -> AudioBuffer {
|
||||
if b.channels.len() == layout.channels() {
|
||||
return b;
|
||||
}
|
||||
AudioBuffer { sample_rate: b.sample_rate, channels: chans::convert_owned(b.channels, layout, mixdown) }
|
||||
}
|
||||
|
||||
/// The layout of a sequence's Mix (`SequenceSettings::audio_master`; mono and adaptive Mixes play
|
||||
/// as stereo).
|
||||
pub fn master_layout(seq: &Sequence) -> Layout {
|
||||
if seq.settings.audio_master == AudioChannels::Surround51 { Layout::Surround51 } else { Layout::Stereo }
|
||||
}
|
||||
|
||||
/// The summed clip audio of one track (clip gain, clip effects, Volume / Channel Volume / Panner,
|
||||
/// audio transitions) for sequence samples `[start, start + frames)`: the track's mixer input.
|
||||
/// audio transitions) for sequence samples `[start, start + frames)`: the track's mixer input
|
||||
/// (2 channels, or 6 for a 5.1 track).
|
||||
pub fn track_input(project: &Project, track: &Track, start: i64, frames: usize, sr: u32, sources: &dyn SourceProvider) -> AudioBuffer {
|
||||
track_input_live(project, track, start, frames, sr, sources, &HashMap::new())
|
||||
}
|
||||
|
||||
/// [`track_input`] with the live mixer overrides: held Clip Mixer controls ([`CLIP_LANE_VOLUME`],
|
||||
/// [`CLIP_LANE_PAN`] on this track) replace the clip's Volume / Panner values while held.
|
||||
pub fn track_input_live(
|
||||
project: &Project,
|
||||
track: &Track,
|
||||
start: i64,
|
||||
frames: usize,
|
||||
sr: u32,
|
||||
sources: &dyn SourceProvider,
|
||||
ovs: &HashMap<(TrackId, String), Override>,
|
||||
) -> AudioBuffer {
|
||||
let w = crate::mixer::width_of(track.channels);
|
||||
let live = ClipLive { vol: ovs.get(&(track.id, CLIP_LANE_VOLUME.to_string())).copied(), pan: ovs.get(&(track.id, CLIP_LANE_PAN.to_string())).copied() };
|
||||
let range = TimeRange::from_bounds(Tick::from_units(start, sr as i64), Tick::from_units(start + frames as i64, sr as i64));
|
||||
let mut tbuf = AudioBuffer::silence(sr, 2, frames);
|
||||
let mut tbuf = AudioBuffer::silence(sr, w, frames);
|
||||
for item in track.items.iter().filter(|i| i.enabled && i.range().overlaps(&range)) {
|
||||
mix_item(project, item, start, frames, sr, sources, &mut tbuf, 1.0);
|
||||
mix_item(project, item, start, frames, sr, sources, &mut tbuf, 1.0, &live);
|
||||
}
|
||||
// audio transitions: attenuate the covered region of each side
|
||||
for tr in &track.transitions {
|
||||
|
|
@ -44,20 +106,20 @@ pub fn track_input(project: &Project, track: &Track, start: i64, frames: usize,
|
|||
for ch in tbuf.channels.iter_mut() {
|
||||
ch[off..off + n].fill(0.0);
|
||||
}
|
||||
let mut a = AudioBuffer::silence(sr, 2, n);
|
||||
let mut b = AudioBuffer::silence(sr, 2, n);
|
||||
let mut a = AudioBuffer::silence(sr, w, n);
|
||||
let mut b = AudioBuffer::silence(sr, w, n);
|
||||
if let Some(f) = from {
|
||||
mix_item(project, f, s0, n, sr, sources, &mut a, 1.0);
|
||||
mix_item(project, f, s0, n, sr, sources, &mut a, 1.0, &live);
|
||||
}
|
||||
if let Some(tt) = to {
|
||||
mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0);
|
||||
mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0, &live);
|
||||
}
|
||||
let dur = (tr.end() - tr.start).to_units_floor(sr as i64).max(1) as f32;
|
||||
let base = (s0 - tr.start.to_units_floor(sr as i64)) as f32;
|
||||
for i in 0..n {
|
||||
let p = (base + i as f32) / dur;
|
||||
let (ga, gb) = transitions::audio_gains(&tr.effect.effect, p);
|
||||
for c in 0..2 {
|
||||
for c in 0..w {
|
||||
tbuf.channels[c][off + i] += a.channels[c][i] * ga + b.channels[c][i] * gb;
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +127,13 @@ pub fn track_input(project: &Project, track: &Track, start: i64, frames: usize,
|
|||
tbuf
|
||||
}
|
||||
|
||||
/// Held Clip Mixer controls of one track.
|
||||
#[derive(Default)]
|
||||
struct ClipLive {
|
||||
vol: Option<Override>,
|
||||
pan: Option<Override>,
|
||||
}
|
||||
|
||||
/// Balance for stereo signals (clip Panner, stereo track pan): centre = unity on both sides, turning
|
||||
/// one way attenuates the other side along the −3 dB constant-power curve (normalised so the
|
||||
/// centre is 0 dB); hard left/right silences the opposite side.
|
||||
|
|
@ -75,7 +144,17 @@ pub fn pan_gains(pan: f32) -> (f32, f32) {
|
|||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mix_item(project: &Project, item: &TrackItem, start: i64, frames: usize, sr: u32, sources: &dyn SourceProvider, out: &mut AudioBuffer, extra: f32) {
|
||||
fn mix_item(
|
||||
project: &Project,
|
||||
item: &TrackItem,
|
||||
start: i64,
|
||||
frames: usize,
|
||||
sr: u32,
|
||||
sources: &dyn SourceProvider,
|
||||
out: &mut AudioBuffer,
|
||||
extra: f32,
|
||||
live: &ClipLive,
|
||||
) {
|
||||
let item_s0 = item.start.to_units_floor(sr as i64);
|
||||
let item_s1 = item.end().to_units_floor(sr as i64);
|
||||
let a0 = start.max(item_s0);
|
||||
|
|
@ -86,15 +165,16 @@ fn mix_item(project: &Project, item: &TrackItem, start: i64, frames: usize, sr:
|
|||
if item.essential.as_ref().is_some_and(|e| e.mute) {
|
||||
return;
|
||||
}
|
||||
let w = out.channels.len();
|
||||
let n = (a1 - a0) as usize;
|
||||
let buf = if let Some(nested) = project.sequence(item.item) {
|
||||
nested_audio(project, item, nested, a0, n, sr, sources)
|
||||
nested_audio(project, item, nested, a0, n, sr, sources, w)
|
||||
} else {
|
||||
let Some(src) = sources.source(item.item) else { return };
|
||||
if !src.info().has_audio() {
|
||||
return;
|
||||
}
|
||||
effected(item, src.as_ref(), a0, n, sr)
|
||||
effected(item, src.as_ref(), a0, n, sr, w)
|
||||
};
|
||||
// gains: clip gain × Volume (keyframed, per 64-sample block) × channel volume × panner
|
||||
let clip_gain = db_to_gain(item.gain_db);
|
||||
|
|
@ -108,41 +188,64 @@ fn mix_item(project: &Project, item: &TrackItem, start: i64, frames: usize, sr:
|
|||
while blk < n {
|
||||
let t_tl = Tick::from_units((a0 + blk as i64).div_euclid(64) * 64, sr as i64);
|
||||
let mt = item.source_time_at(t_tl);
|
||||
let v = vol.map(|e| db_to_gain(e.f64_at("level", mt))).unwrap_or(1.0);
|
||||
let level = |auto: f64| live.vol.map(|o| o.at(t_tl, auto)).unwrap_or(auto);
|
||||
let v = match vol {
|
||||
Some(e) => db_to_gain(level(e.f64_at("level", mt))),
|
||||
None if live.vol.is_some() => db_to_gain(level(0.0)),
|
||||
None => 1.0,
|
||||
};
|
||||
let (cl, cr) = chv.map(|e| (db_to_gain(e.f64_at("left", mt)), db_to_gain(e.f64_at("right", mt)))).unwrap_or((1.0, 1.0));
|
||||
let (pl, pr) = pan.map(|e| pan_gains((e.f64_at("balance", mt) / 100.0) as f32)).unwrap_or((1.0, 1.0));
|
||||
let balance = |auto: f64| live.pan.map(|o| o.at(t_tl, auto)).unwrap_or(auto);
|
||||
let (pl, pr) = match pan {
|
||||
Some(e) => pan_gains((balance(e.f64_at("balance", mt)) / 100.0) as f32),
|
||||
None if live.pan.is_some() => pan_gains((balance(0.0) / 100.0) as f32),
|
||||
None => (1.0, 1.0),
|
||||
};
|
||||
let g = clip_gain * v * extra;
|
||||
let end = (((a0 + blk as i64).div_euclid(64) + 1) * 64 - a0).min(n as i64) as usize;
|
||||
for i in blk..end {
|
||||
let (l, r) = (buf[0][i], buf[1][i]);
|
||||
out.channels[0][off + i] += l * g * cl * pl;
|
||||
out.channels[1][off + i] += r * g * cr * pr;
|
||||
if w == 2 {
|
||||
for i in blk..end {
|
||||
let (l, r) = (buf[0][i], buf[1][i]);
|
||||
out.channels[0][off + i] += l * g * cl * pl;
|
||||
out.channels[1][off + i] += r * g * cr * pr;
|
||||
}
|
||||
} else {
|
||||
for (c, (dst, src)) in out.channels.iter_mut().zip(&buf).enumerate() {
|
||||
let gc = g * match c {
|
||||
chans::L => cl,
|
||||
chans::R => cr,
|
||||
_ => 1.0,
|
||||
};
|
||||
for i in blk..end {
|
||||
dst[off + i] += src[i] * gc;
|
||||
}
|
||||
}
|
||||
}
|
||||
blk = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// A nested sequence's mix for timeline samples `[a0, a0 + n)` of the clip that shows it. A
|
||||
/// multi-camera clip plays the audio of its source's audio setting: camera 1, all cameras, or (when
|
||||
/// switching audio) the angle the clip selects.
|
||||
fn nested_audio(project: &Project, item: &TrackItem, nested: &Sequence, a0: i64, n: usize, sr: u32, sources: &dyn SourceProvider) -> [Vec<f32>; 2] {
|
||||
/// A nested sequence's mix for timeline samples `[a0, a0 + n)` of the clip that shows it, at width
|
||||
/// `w`. A multi-camera clip plays the audio of its source's audio setting: camera 1, all cameras,
|
||||
/// or (when switching audio) the angle the clip selects.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn nested_audio(project: &Project, item: &TrackItem, nested: &Sequence, a0: i64, n: usize, sr: u32, sources: &dyn SourceProvider, w: usize) -> Vec<Vec<f32>> {
|
||||
let rel0 = a0 - item.start.to_units_floor(sr as i64) + item.source_in.to_units_floor(sr as i64);
|
||||
let b = if nested.multicam.is_some() {
|
||||
let q = nested.with_angle_audio(item.multicam_angle(nested));
|
||||
mix_sequence(project, &q, rel0, n, sources)
|
||||
let q = if nested.multicam.is_some() {
|
||||
std::borrow::Cow::Owned(nested.with_angle_audio(item.multicam_angle(nested)))
|
||||
} else {
|
||||
mix_sequence(project, nested, rel0, n, sources)
|
||||
std::borrow::Cow::Borrowed(nested)
|
||||
};
|
||||
let mut it = b.channels.into_iter();
|
||||
let l = it.next().unwrap_or_else(|| vec![0.0; n]);
|
||||
let r = it.next().unwrap_or_else(|| l.clone());
|
||||
[l, r]
|
||||
let b = crate::mixer::mix_graph(project, &q, rel0, n, sources, None);
|
||||
let mut v = to_layout(b, Layout::from_channels(w), Mixdown::FrontRear).channels;
|
||||
v.resize(w, vec![0.0; n]);
|
||||
v
|
||||
}
|
||||
|
||||
/// The clip's audio after speed/reverse and clip effects (before clip gain, Volume and Panner) for
|
||||
/// timeline samples `[a0, a0 + n)`.
|
||||
fn effected(item: &TrackItem, src: &dyn filmcraft_media::MediaSource, a0: i64, n: usize, sr: u32) -> [Vec<f32>; 2] {
|
||||
let read = |x0: i64, len: usize| raw_stereo(item, src, x0, len, sr);
|
||||
/// timeline samples `[a0, a0 + n)`, `w` channels.
|
||||
fn effected(item: &TrackItem, src: &dyn filmcraft_media::MediaSource, a0: i64, n: usize, sr: u32, w: usize) -> Vec<Vec<f32>> {
|
||||
let read = |x0: i64, len: usize| raw_channels(item, src, x0, len, sr, w);
|
||||
if crate::audio_fx::has_effects(item) { crate::audio_fx::process(item, a0, n, sr, &read) } else { read(a0, n) }
|
||||
}
|
||||
|
||||
|
|
@ -162,7 +265,7 @@ pub fn clip_signal(item: &TrackItem, start: i64, frames: usize, sr: u32, sources
|
|||
let n = (a1 - a0) as usize;
|
||||
let off = (a0 - start) as usize;
|
||||
let g = db_to_gain(item.gain_db);
|
||||
let buf = effected(item, src.as_ref(), a0, n, sr);
|
||||
let buf = effected(item, src.as_ref(), a0, n, sr, 2);
|
||||
for (dst, b) in out.iter_mut().zip(&buf) {
|
||||
for (d, x) in dst[off..off + n].iter_mut().zip(b) {
|
||||
*d = x * g;
|
||||
|
|
@ -172,10 +275,10 @@ pub fn clip_signal(item: &TrackItem, start: i64, frames: usize, sr: u32, sources
|
|||
Some(out)
|
||||
}
|
||||
|
||||
/// The clip's audio (after speed/reverse) for timeline samples `[x0, x0 + len)` as stereo, with
|
||||
/// silence outside the clip. Mono sources are duplicated to both channels.
|
||||
fn raw_stereo(item: &TrackItem, src: &dyn filmcraft_media::MediaSource, x0: i64, len: usize, sr: u32) -> [Vec<f32>; 2] {
|
||||
let mut out = [vec![0.0f32; len], vec![0.0f32; len]];
|
||||
/// The clip's audio (after speed/reverse) for timeline samples `[x0, x0 + len)` as `w` channels
|
||||
/// (2: stereo, mono sources on both sides; 6: 5.1), with silence outside the clip.
|
||||
fn raw_channels(item: &TrackItem, src: &dyn filmcraft_media::MediaSource, x0: i64, len: usize, sr: u32, w: usize) -> Vec<Vec<f32>> {
|
||||
let mut out = vec![vec![0.0f32; len]; w];
|
||||
let item_s0 = item.start.to_units_floor(sr as i64);
|
||||
let item_s1 = item.end().to_units_floor(sr as i64);
|
||||
let a0 = x0.max(item_s0);
|
||||
|
|
@ -187,7 +290,10 @@ fn raw_stereo(item: &TrackItem, src: &dyn filmcraft_media::MediaSource, x0: i64,
|
|||
let off = (a0 - x0) as usize;
|
||||
let speed = item.speed.abs().max(1e-6);
|
||||
let src_start_ticks = item.source_time_at(Tick::from_units(a0, sr as i64));
|
||||
let buf = if (speed - 1.0).abs() < 1e-9 && !item.reverse {
|
||||
let remixed = crate::remix::read(item, &|m0, len| src.audio(m0, len, sr).ok(), a0 - item_s0, n, sr);
|
||||
let buf = if remixed.is_some() {
|
||||
remixed
|
||||
} else if (speed - 1.0).abs() < 1e-9 && !item.reverse {
|
||||
src.audio(src_start_ticks.to_units_floor(sr as i64), n, sr).ok()
|
||||
} else {
|
||||
// varispeed: read a longer span and resample linearly
|
||||
|
|
@ -209,15 +315,47 @@ fn raw_stereo(item: &TrackItem, src: &dyn filmcraft_media::MediaSource, x0: i64,
|
|||
})
|
||||
};
|
||||
let Some(buf) = buf else { return out };
|
||||
let (left, right) = source_pair(item, buf.channel_count());
|
||||
for (c, dst) in out.iter_mut().enumerate() {
|
||||
let ch = &buf.channels[if c == 0 { left } else { right }];
|
||||
if buf.channels.is_empty() {
|
||||
return out;
|
||||
}
|
||||
if w == 2 {
|
||||
let (left, right) = source_pair(item, buf.channel_count());
|
||||
for (c, dst) in out.iter_mut().enumerate() {
|
||||
let ch = &buf.channels[if c == 0 { left } else { right }];
|
||||
let m = n.min(ch.len());
|
||||
dst[off..off + m].copy_from_slice(&ch[..m]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// 5.1 track: pick or upmix the source channels
|
||||
let picked: Vec<&[f32]> = source_51(item, buf.channel_count()).iter().map(|&c| buf.channels[c].as_slice()).collect();
|
||||
let layout = Layout::from_channels(picked.len());
|
||||
let conv =
|
||||
if picked.len() == 6 { picked.iter().map(|c| c.to_vec()).collect() } else { chans::convert(&picked, layout, Layout::Surround51, Mixdown::FrontRear) };
|
||||
for (dst, ch) in out.iter_mut().zip(&conv) {
|
||||
let m = n.min(ch.len());
|
||||
dst[off..off + m].copy_from_slice(&ch[..m]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The source channels a clip on a 5.1 track plays: six picked channels (Modify ▸ Audio Channels),
|
||||
/// else the source's first six; a mono pick or source gives one channel (upmixed to C), a stereo
|
||||
/// pick or 2–5 channel source gives two (upmixed to L/R).
|
||||
pub fn source_51(item: &TrackItem, available: usize) -> Vec<usize> {
|
||||
let ok = |c: u16| if (c as usize) < available { c as usize } else { 0 };
|
||||
match item.source_channels.len() {
|
||||
0 => match available {
|
||||
0 | 1 => vec![0],
|
||||
2..=5 => vec![0, 1],
|
||||
_ => (0..6).collect(),
|
||||
},
|
||||
1 => vec![ok(item.source_channels[0])],
|
||||
2..=5 => vec![ok(item.source_channels[0]), ok(item.source_channels[1])],
|
||||
_ => item.source_channels.iter().take(6).map(|&c| ok(c)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The source channels (left, right) an audio clip plays: its `source_channels` (one = mono on
|
||||
/// both sides; Modify ▸ Audio Channels, Breakout to Mono), else the first two (mono sources on
|
||||
/// both sides). Channels the source doesn't have fall back to the first.
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ struct Chain {
|
|||
next_out: i64,
|
||||
}
|
||||
|
||||
type Key = (u64, u64, u32);
|
||||
type Key = (u64, u64, u32, usize);
|
||||
|
||||
fn cache() -> &'static Mutex<HashMap<Key, Vec<Chain>>> {
|
||||
static C: OnceLock<Mutex<HashMap<Key, Vec<Chain>>>> = OnceLock::new();
|
||||
|
|
@ -342,9 +342,9 @@ fn structure_hash(item: &TrackItem) -> u64 {
|
|||
|
||||
const BLOCK: usize = 256;
|
||||
|
||||
/// Run `input` (stereo, `input[c].len()` samples starting at timeline sample `in0`) through the
|
||||
/// Run `input` (planar, `input[c].len()` samples starting at timeline sample `in0`) through the
|
||||
/// chain, setting keyframed parameters per block.
|
||||
fn run(chain: &mut Chain, item: &TrackItem, in0: i64, input: &mut [Vec<f32>; 2], sr: u32) {
|
||||
fn run(chain: &mut Chain, item: &TrackItem, in0: i64, input: &mut [Vec<f32>], sr: u32) {
|
||||
let act = active(item);
|
||||
let n = input[0].len();
|
||||
let mut i = 0;
|
||||
|
|
@ -356,8 +356,7 @@ fn run(chain: &mut Chain, item: &TrackItem, in0: i64, input: &mut [Vec<f32>; 2],
|
|||
for ((e, m), d) in act.iter().zip(chain.fx.iter_mut()) {
|
||||
(m.apply)(d.as_mut(), e, mt);
|
||||
}
|
||||
let [l, r] = input;
|
||||
let mut chans: [&mut [f32]; 2] = [&mut l[i..end], &mut r[i..end]];
|
||||
let mut chans: Vec<&mut [f32]> = input.iter_mut().map(|c| &mut c[i..end]).collect();
|
||||
for d in chain.fx.iter_mut() {
|
||||
d.process(&mut chans);
|
||||
}
|
||||
|
|
@ -366,9 +365,11 @@ fn run(chain: &mut Chain, item: &TrackItem, in0: i64, input: &mut [Vec<f32>; 2],
|
|||
}
|
||||
|
||||
/// Process the item's audio for timeline samples `[a0, a0 + n)`. `read(x0, len)` returns the clip's
|
||||
/// raw stereo audio for timeline samples `[x0, x0 + len)` (silence outside the clip).
|
||||
pub fn process(item: &TrackItem, a0: i64, n: usize, sr: u32, read: &dyn Fn(i64, usize) -> [Vec<f32>; 2]) -> [Vec<f32>; 2] {
|
||||
let key = (item.id.0, structure_hash(item), sr);
|
||||
/// raw planar audio (2 channels, or 6 on a 5.1 track) for timeline samples `[x0, x0 + len)`
|
||||
/// (silence outside the clip).
|
||||
pub fn process(item: &TrackItem, a0: i64, n: usize, sr: u32, read: &dyn Fn(i64, usize) -> Vec<Vec<f32>>) -> Vec<Vec<f32>> {
|
||||
let width = read(a0, 0).len().max(1);
|
||||
let key = (item.id.0, structure_hash(item), sr, width);
|
||||
let taken = {
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
c.get_mut(&key).and_then(|v| v.iter().position(|ch| ch.next_out == a0).map(|i| v.swap_remove(i)))
|
||||
|
|
@ -385,7 +386,7 @@ pub fn process(item: &TrackItem, a0: i64, n: usize, sr: u32, read: &dyn Fn(i64,
|
|||
let pre = (pre_s * sr as f64).ceil() as usize;
|
||||
let mut fx: Vec<Box<dyn AudioEffect>> = Vec::new();
|
||||
for (_, m) in &act {
|
||||
if let Some(d) = filmcraft_audio_dsp::create_effect(m.dsp, sr as f32, 2) {
|
||||
if let Some(d) = filmcraft_audio_dsp::create_effect(m.dsp, sr as f32, width) {
|
||||
fx.push(d);
|
||||
}
|
||||
}
|
||||
|
|
@ -398,7 +399,7 @@ pub fn process(item: &TrackItem, a0: i64, n: usize, sr: u32, read: &dyn Fn(i64,
|
|||
let in0 = chain.next_out + chain.latency as i64;
|
||||
run(&mut chain, item, in0, &mut input, sr);
|
||||
chain.next_out = a0 + n as i64;
|
||||
let out = [input[0][skip..].to_vec(), input[1][skip..].to_vec()];
|
||||
let out: Vec<Vec<f32>> = input.iter().map(|c| c[skip..].to_vec()).collect();
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let v = c.entry(key).or_default();
|
||||
v.push(chain);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub mod multicam;
|
|||
pub mod offline;
|
||||
pub mod plan;
|
||||
pub mod preview;
|
||||
pub mod remix;
|
||||
pub mod scene;
|
||||
pub mod track;
|
||||
pub mod transitions;
|
||||
|
|
|
|||
|
|
@ -26,8 +26,15 @@
|
|||
//! is solo-muted (silent, sends included). Track mute sits after the pre-fader sends, so
|
||||
//! pre-fader sends of a muted track keep sending, as in Premiere.
|
||||
//! * **Pan law.** Mono tracks pan with the −3 dB constant-power law (centre = −3 dB per side);
|
||||
//! stereo (and, for now, 5.1) tracks and sends use balance (centre = unity, the far side follows
|
||||
//! the same constant-power curve normalised to the centre).
|
||||
//! stereo tracks and sends use balance (centre = unity, the far side follows the same
|
||||
//! constant-power curve normalised to the centre).
|
||||
//! * **Channels.** Buses are 2 channels wide, or 6 (L, R, C, LFE, Ls, Rs) for 5.1 tracks, 5.1
|
||||
//! submixes and a 5.1 Mix (`SequenceSettings::audio_master`). A strip feeding a 5.1 bus pans with
|
||||
//! the 5.1 panner (lanes `pan51.x`, `pan51.y`, `pan51.center`, `pan51.lfe`;
|
||||
//! [`filmcraft_audio_dsp::channels::pan51_matrix`]); a 5.1 strip feeding a stereo bus is folded
|
||||
//! with the ITU-R BS.775 downmix and then balanced. Sends convert between widths with the same
|
||||
//! BS.775 matrices. [`mix_graph`] returns the Mix's width; [`crate::audio::mix_sequence`] always
|
||||
//! returns stereo.
|
||||
//!
|
||||
//! [`LiveMix`] carries what the UI does while playing: control overrides while a fader is held
|
||||
//! (with Touch's ramp back to the automation), per-strip meters, and the newest project snapshot.
|
||||
|
|
@ -38,14 +45,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use filmcraft_audio_dsp::AudioEffect;
|
||||
use filmcraft_audio_dsp::channels::{self as chans, Layout, Mixdown, Pan51};
|
||||
use filmcraft_frame::AudioBuffer;
|
||||
use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, fx_lane, send_lane};
|
||||
use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_PAN51_CENTER, LANE_PAN51_LFE, LANE_PAN51_X, LANE_PAN51_Y, LANE_VOLUME, fx_lane, send_lane};
|
||||
use filmcraft_project::{AudioChannels, EffectInstance, InputMap, Param, ParamValue, Project, Sequence, Track, TrackId};
|
||||
use filmcraft_time::Tick;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::SourceProvider;
|
||||
use crate::audio::{pan_gains, track_input};
|
||||
use crate::audio::{pan_gains, track_input_live};
|
||||
use crate::audio_fx::{Mapping, mapping};
|
||||
|
||||
/// Strip id of the Mix (master) track in [`LiveMix`] and commands.
|
||||
|
|
@ -70,7 +78,9 @@ pub struct Override {
|
|||
pub struct LiveMix {
|
||||
overrides: Mutex<HashMap<(TrackId, String), Override>>,
|
||||
active: AtomicBool,
|
||||
meters: Mutex<HashMap<TrackId, [f32; 2]>>,
|
||||
meters: Mutex<HashMap<TrackId, Vec<f32>>>,
|
||||
/// Peak levels of the recording input (voice-over), per input channel.
|
||||
input_meter: Mutex<Vec<f32>>,
|
||||
project: Mutex<Option<Arc<Project>>>,
|
||||
}
|
||||
|
||||
|
|
@ -117,18 +127,37 @@ impl LiveMix {
|
|||
pub fn overrides(&self) -> HashMap<(TrackId, String), Override> {
|
||||
lock(&self.overrides).clone()
|
||||
}
|
||||
/// Peak levels (linear, L/R, post-fader) per strip since the last call; [`MASTER`] is the Mix.
|
||||
pub fn take_meters(&self) -> HashMap<TrackId, [f32; 2]> {
|
||||
/// Peak levels (linear, post-fader, one per channel of the strip: 2, or 6 for 5.1) per strip
|
||||
/// since the last call; [`MASTER`] is the Mix.
|
||||
pub fn take_meters(&self) -> HashMap<TrackId, Vec<f32>> {
|
||||
std::mem::take(&mut *lock(&self.meters))
|
||||
}
|
||||
fn post_meters(&self, m: &[(TrackId, [f32; 2])]) {
|
||||
fn post_meters(&self, m: &[(TrackId, Vec<f32>)]) {
|
||||
let mut g = lock(&self.meters);
|
||||
for (id, p) in m {
|
||||
let e = g.entry(*id).or_insert([0.0; 2]);
|
||||
e[0] = e[0].max(p[0]);
|
||||
e[1] = e[1].max(p[1]);
|
||||
let e = g.entry(*id).or_default();
|
||||
if e.len() < p.len() {
|
||||
e.resize(p.len(), 0.0);
|
||||
}
|
||||
for (a, b) in e.iter_mut().zip(p) {
|
||||
*a = a.max(*b);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Post the peak levels of the recording input (voice-over capture), per input channel.
|
||||
pub fn post_input_meter(&self, peaks: &[f32]) {
|
||||
let mut g = lock(&self.input_meter);
|
||||
if g.len() < peaks.len() {
|
||||
g.resize(peaks.len(), 0.0);
|
||||
}
|
||||
for (a, b) in g.iter_mut().zip(peaks) {
|
||||
*a = a.max(*b);
|
||||
}
|
||||
}
|
||||
/// Input peak levels since the last call (Audio Track Mixer ▸ Meter Input(s) Only).
|
||||
pub fn take_input_meter(&self) -> Vec<f32> {
|
||||
std::mem::take(&mut *lock(&self.input_meter))
|
||||
}
|
||||
/// Publish the newest project snapshot (playback picks up edits made while it runs).
|
||||
pub fn publish_project(&self, p: Arc<Project>) {
|
||||
*lock(&self.project) = Some(p);
|
||||
|
|
@ -138,6 +167,26 @@ impl LiveMix {
|
|||
}
|
||||
}
|
||||
|
||||
impl Override {
|
||||
/// The value at `t` given the automation value `auto` there: the held value, or (after a
|
||||
/// Touch release) a linear ramp back to the automation.
|
||||
pub fn at(&self, t: Tick, auto: f64) -> f64 {
|
||||
match self.release {
|
||||
None => self.value,
|
||||
Some((r, d)) => {
|
||||
if t < r {
|
||||
self.value
|
||||
} else if t >= r + d {
|
||||
auto
|
||||
} else {
|
||||
let u = (t - r).0 as f64 / d.0.max(1) as f64;
|
||||
self.value + (auto - self.value) * u
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- pan laws
|
||||
|
||||
/// −3 dB constant-power pan of a mono signal: (left, right) gains; centre = 0.7071 each.
|
||||
|
|
@ -196,16 +245,7 @@ impl<'a> Lane<'a> {
|
|||
match self.ov {
|
||||
None => self.auto(t),
|
||||
Some(Override { value, release: None }) => value,
|
||||
Some(Override { value, release: Some((r, d)) }) => {
|
||||
if t < r {
|
||||
value
|
||||
} else if t >= r + d {
|
||||
self.auto(t)
|
||||
} else {
|
||||
let u = (t - r).0 as f64 / d.0.max(1) as f64;
|
||||
value + (self.auto(t) - value) * u
|
||||
}
|
||||
}
|
||||
Some(o) => o.at(t, self.auto(t)),
|
||||
}
|
||||
}
|
||||
/// Per-sample values for content samples `c0 ..`, mapped through `f`, into `out`.
|
||||
|
|
@ -249,6 +289,8 @@ struct Insert<'a> {
|
|||
struct Strip<'a> {
|
||||
track: Cow<'a, Track>,
|
||||
kind: Kind,
|
||||
/// Bus width: 2, or 6 for 5.1.
|
||||
width: usize,
|
||||
pre: Vec<Insert<'a>>,
|
||||
post: Vec<Insert<'a>>,
|
||||
/// Silent (solo-muted, or muted with nothing tapping before the mute).
|
||||
|
|
@ -297,13 +339,28 @@ impl<'a> Plan<'a> {
|
|||
fn build(seq: &'a Sequence, ovs: &HashMap<(TrackId, String), Override>) -> Plan<'a> {
|
||||
let mut strips: Vec<Strip<'a>> = Vec::new();
|
||||
for t in &seq.audio_tracks {
|
||||
strips.push(Strip { track: Cow::Borrowed(t), kind: Kind::Track, pre: inserts(t, false), post: inserts(t, true), silent: false });
|
||||
strips.push(Strip {
|
||||
track: Cow::Borrowed(t),
|
||||
kind: Kind::Track,
|
||||
width: width_of(t.channels),
|
||||
pre: inserts(t, false),
|
||||
post: inserts(t, true),
|
||||
silent: false,
|
||||
});
|
||||
}
|
||||
for t in &seq.submix_tracks {
|
||||
strips.push(Strip { track: Cow::Borrowed(t), kind: Kind::Submix, pre: inserts(t, false), post: inserts(t, true), silent: false });
|
||||
strips.push(Strip {
|
||||
track: Cow::Borrowed(t),
|
||||
kind: Kind::Submix,
|
||||
width: width_of(t.channels),
|
||||
pre: inserts(t, false),
|
||||
post: inserts(t, true),
|
||||
silent: false,
|
||||
});
|
||||
}
|
||||
let m = seq.master_strip();
|
||||
strips.push(Strip { track: Cow::Owned(m), kind: Kind::Master, pre: Vec::new(), post: Vec::new(), silent: false });
|
||||
let mw = width_of(m.channels);
|
||||
strips.push(Strip { track: Cow::Owned(m), kind: Kind::Master, width: mw, pre: Vec::new(), post: Vec::new(), silent: false });
|
||||
let mi = strips.len() - 1;
|
||||
// master inserts borrow from the sequence (same effects as the owned copy)
|
||||
{
|
||||
|
|
@ -413,6 +470,7 @@ impl<'a> Plan<'a> {
|
|||
for s in &self.strips {
|
||||
s.track.id.hash(&mut h);
|
||||
s.kind.hash(&mut h);
|
||||
s.width.hash(&mut h);
|
||||
for (side, v) in [(0u8, &s.pre), (1u8, &s.post)] {
|
||||
for i in v.iter() {
|
||||
(side, i.slot, &i.effect.effect, i.map.dsp).hash(&mut h);
|
||||
|
|
@ -433,30 +491,38 @@ impl<'a> Plan<'a> {
|
|||
// ------------------------------------------------------------------------------------- state
|
||||
|
||||
struct DelayLine {
|
||||
buf: Vec<[f32; 2]>,
|
||||
/// One ring per channel (all `len` long; empty = no delay).
|
||||
buf: Vec<Vec<f32>>,
|
||||
len: usize,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl DelayLine {
|
||||
fn new(len: usize) -> Self {
|
||||
DelayLine { buf: vec![[0.0; 2]; len], pos: 0 }
|
||||
fn new(len: usize, channels: usize) -> Self {
|
||||
DelayLine { buf: if len == 0 { Vec::new() } else { vec![vec![0.0; len]; channels] }, len, pos: 0 }
|
||||
}
|
||||
fn is_empty(&self) -> bool {
|
||||
self.len == 0
|
||||
}
|
||||
/// Delay `x` in place.
|
||||
fn run(&mut self, x: &mut [Vec<f32>; 2]) {
|
||||
if self.buf.is_empty() {
|
||||
fn run(&mut self, x: &mut Bus) {
|
||||
if self.len == 0 {
|
||||
return;
|
||||
}
|
||||
let n = self.buf.len();
|
||||
for i in 0..x[0].len() {
|
||||
let out = self.buf[self.pos];
|
||||
self.buf[self.pos] = [x[0][i], x[1][i]];
|
||||
x[0][i] = out[0];
|
||||
x[1][i] = out[1];
|
||||
self.pos = (self.pos + 1) % n;
|
||||
let n = self.len;
|
||||
let m = x.first().map_or(0, Vec::len);
|
||||
let mut pos = self.pos;
|
||||
for (ring, ch) in self.buf.iter_mut().zip(x.iter_mut()) {
|
||||
pos = self.pos;
|
||||
for s in ch.iter_mut().take(m) {
|
||||
std::mem::swap(&mut ring[pos], s);
|
||||
pos = (pos + 1) % n;
|
||||
}
|
||||
}
|
||||
self.pos = pos;
|
||||
}
|
||||
fn clear(&mut self) {
|
||||
self.buf.fill([0.0; 2]);
|
||||
self.buf.iter_mut().for_each(|c| c.fill(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -478,13 +544,15 @@ struct GraphState {
|
|||
}
|
||||
|
||||
fn build_state(plan: &Plan, sr: u32) -> GraphState {
|
||||
let mk = |v: &[Insert]| -> Vec<Box<dyn AudioEffect>> { v.iter().filter_map(|i| filmcraft_audio_dsp::create_effect(i.map.dsp, sr as f32, 2)).collect() };
|
||||
let mk = |v: &[Insert], w: usize| -> Vec<Box<dyn AudioEffect>> {
|
||||
v.iter().filter_map(|i| filmcraft_audio_dsp::create_effect(i.map.dsp, sr as f32, w)).collect()
|
||||
};
|
||||
let mut strips: Vec<StripState> = plan
|
||||
.strips
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let pre = mk(&s.pre);
|
||||
let post = mk(&s.post);
|
||||
let pre = mk(&s.pre, s.width);
|
||||
let post = mk(&s.post, s.width);
|
||||
let lat_pre = pre.iter().map(|d| d.latency()).sum();
|
||||
let lat_post = post.iter().map(|d| d.latency()).sum();
|
||||
StripState { pre, post, lat_pre, lat_post, in_lat: 0, delays: Vec::new() }
|
||||
|
|
@ -498,7 +566,7 @@ fn build_state(plan: &Plan, sr: u32) -> GraphState {
|
|||
}
|
||||
for r in &plan.routes {
|
||||
let d = strips[r.target].in_lat - tap_lat(&strips[r.src], r.tap);
|
||||
strips[r.src].delays.push(DelayLine::new(d));
|
||||
strips[r.src].delays.push(DelayLine::new(d, plan.strips[r.target].width));
|
||||
}
|
||||
let m = strips.len() - 1;
|
||||
let latency = strips[m].in_lat + strips[m].lat_pre + strips[m].lat_post;
|
||||
|
|
@ -519,15 +587,50 @@ pub fn graph_latency(seq: &Sequence) -> usize {
|
|||
|
||||
// ------------------------------------------------------------------------------------- processing
|
||||
|
||||
type Stereo = [Vec<f32>; 2];
|
||||
/// Planar bus signal (2 or 6 channels).
|
||||
type Bus = Vec<Vec<f32>>;
|
||||
|
||||
fn silent(n: usize) -> Stereo {
|
||||
[vec![0.0; n], vec![0.0; n]]
|
||||
fn silent(w: usize, n: usize) -> Bus {
|
||||
vec![vec![0.0; n]; w]
|
||||
}
|
||||
|
||||
/// Internal bus width of a channel format: 6 for 5.1, 2 otherwise (mono strips carry the same
|
||||
/// signal on both channels).
|
||||
pub fn width_of(c: AudioChannels) -> usize {
|
||||
if c == AudioChannels::Surround51 { 6 } else { 2 }
|
||||
}
|
||||
|
||||
/// The layout a strip's signal is positioned as by the 5.1 panner.
|
||||
fn source_layout(tr: &Track, width: usize) -> Layout {
|
||||
match (width, tr.channels) {
|
||||
(6, _) => Layout::Surround51,
|
||||
(_, AudioChannels::Mono) => Layout::Mono,
|
||||
_ => Layout::Stereo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a strip signal to a bus width (BS.775 matrices; a mono strip upmixes to the centre).
|
||||
fn convert_width(buf: &Bus, layout: Layout, to: usize) -> Bus {
|
||||
if buf.len() == to {
|
||||
return buf.clone();
|
||||
}
|
||||
let n = buf.first().map_or(0, Vec::len);
|
||||
match (layout, to) {
|
||||
(Layout::Mono, 6) => {
|
||||
let mut v = silent(6, n);
|
||||
v[chans::C] = buf[0].clone();
|
||||
v
|
||||
}
|
||||
_ => {
|
||||
let refs: Vec<&[f32]> = buf.iter().map(Vec::as_slice).collect();
|
||||
chans::convert(&refs, Layout::from_channels(buf.len()), Layout::from_channels(to), Mixdown::FrontRear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `chain` over `buf` (input-time samples from `x0`) with parameters at content time
|
||||
/// `x - lat` updated on the absolute [`PARAM_BLOCK`] grid.
|
||||
fn run_inserts(chain: &mut [Box<dyn AudioEffect>], ins: &[Insert], buf: &mut Stereo, x0: i64, lat: usize, sr: u32) {
|
||||
fn run_inserts(chain: &mut [Box<dyn AudioEffect>], ins: &[Insert], buf: &mut Bus, x0: i64, lat: usize, sr: u32) {
|
||||
if chain.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -541,8 +644,7 @@ fn run_inserts(chain: &mut [Box<dyn AudioEffect>], ins: &[Insert], buf: &mut Ste
|
|||
for (d, e) in chain.iter_mut().zip(ins) {
|
||||
(e.map.apply)(d.as_mut(), &e.effect, t);
|
||||
}
|
||||
let [l, r] = buf;
|
||||
let mut ch: [&mut [f32]; 2] = [&mut l[i..end], &mut r[i..end]];
|
||||
let mut ch: Vec<&mut [f32]> = buf.iter_mut().map(|c| &mut c[i..end]).collect();
|
||||
for d in chain.iter_mut() {
|
||||
d.process(&mut ch);
|
||||
}
|
||||
|
|
@ -551,7 +653,7 @@ fn run_inserts(chain: &mut [Box<dyn AudioEffect>], ins: &[Insert], buf: &mut Ste
|
|||
}
|
||||
|
||||
/// Multiply by a per-sample (or constant) gain pair.
|
||||
fn apply_gain(buf: &mut Stereo, g: Option<f32>, per: &[f32]) {
|
||||
fn apply_gain(buf: &mut Bus, g: Option<f32>, per: &[f32]) {
|
||||
match g {
|
||||
Some(1.0) => {}
|
||||
Some(g) => {
|
||||
|
|
@ -572,7 +674,7 @@ fn apply_gain(buf: &mut Stereo, g: Option<f32>, per: &[f32]) {
|
|||
}
|
||||
|
||||
/// Apply a pan/balance lane (values −100 … 100) with `law`.
|
||||
fn apply_pan(buf: &mut Stereo, lane: &Lane, c0: i64, sr: u32, law: fn(f32) -> (f32, f32), scratch: &mut [f32]) {
|
||||
fn apply_pan(buf: &mut Bus, lane: &Lane, c0: i64, sr: u32, law: fn(f32) -> (f32, f32), scratch: &mut [f32]) {
|
||||
match lane.fill(c0, sr, scratch, |v| v as f32) {
|
||||
Some(p) => {
|
||||
let (gl, gr) = law(p / 100.0);
|
||||
|
|
@ -592,35 +694,54 @@ fn apply_pan(buf: &mut Stereo, lane: &Lane, c0: i64, sr: u32, law: fn(f32) -> (f
|
|||
}
|
||||
|
||||
struct StripOut {
|
||||
/// (route index in the plan, delayed signal)
|
||||
routes: Vec<(usize, Stereo)>,
|
||||
meter: [f32; 2],
|
||||
/// (route index in the plan, delayed signal at the target's width)
|
||||
routes: Vec<(usize, Bus)>,
|
||||
meter: Vec<f32>,
|
||||
/// Final signal (Mix only).
|
||||
out: Option<Stereo>,
|
||||
out: Option<Bus>,
|
||||
}
|
||||
|
||||
/// The 5.1 panner of a strip feeding a 5.1 bus: `buf` (the strip's width) → 6 channels.
|
||||
fn pan51(buf: &Bus, layout: Layout, tr: &Track, id: TrackId, ovs: &HashMap<(TrackId, String), Override>, c0: i64, sr: u32) -> Bus {
|
||||
let n = buf.first().map_or(0, Vec::len);
|
||||
let lanes = [LANE_PAN51_X, LANE_PAN51_Y, LANE_PAN51_CENTER, LANE_PAN51_LFE].map(|k| Lane::new(tr, id, k, ovs));
|
||||
let input: Vec<&[f32]> = if layout == Layout::Mono { vec![buf[0].as_slice()] } else { buf.iter().map(Vec::as_slice).collect() };
|
||||
let pan = |v: [f64; 4]| Pan51 { x: (v[0] / 100.0) as f32, y: (v[1] / 100.0) as f32, center: (v[2] / 100.0) as f32, lfe: db_gain(v[3]) };
|
||||
let consts: Vec<Option<f64>> = lanes.iter().map(Lane::constant).collect();
|
||||
if consts.iter().all(Option::is_some) {
|
||||
let m = chans::pan51_matrix(layout, pan([0, 1, 2, 3].map(|i| consts[i].unwrap_or(0.0))));
|
||||
return chans::apply_matrix(&m, &input);
|
||||
}
|
||||
let mut out = silent(6, n);
|
||||
for i in 0..n {
|
||||
let t = Tick::from_units(c0 + i as i64, sr as i64);
|
||||
let m = chans::pan51_matrix(layout, pan([0, 1, 2, 3].map(|k| consts[k].unwrap_or_else(|| lanes[k].at(t)))));
|
||||
for (o, row) in m.iter().enumerate() {
|
||||
let mut acc = 0.0f32;
|
||||
for (g, x) in row.iter().zip(&input) {
|
||||
acc += g * x[i];
|
||||
}
|
||||
out[o][i] = acc;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_strip(
|
||||
plan: &Plan,
|
||||
si: usize,
|
||||
st: &mut StripState,
|
||||
mut buf: Stereo,
|
||||
x0: i64,
|
||||
skip: usize,
|
||||
sr: u32,
|
||||
ovs: &HashMap<(TrackId, String), Override>,
|
||||
) -> StripOut {
|
||||
fn run_strip(plan: &Plan, si: usize, st: &mut StripState, mut buf: Bus, x0: i64, skip: usize, sr: u32, ovs: &HashMap<(TrackId, String), Override>) -> StripOut {
|
||||
let s = &plan.strips[si];
|
||||
let tr: &Track = &s.track;
|
||||
let id = tr.id;
|
||||
let n = buf[0].len();
|
||||
let my_routes: Vec<(usize, &RouteSpec)> = plan.routes.iter().enumerate().filter(|(_, r)| r.src == si).collect();
|
||||
let mut outs: Vec<(usize, Stereo)> = Vec::new();
|
||||
let mut outs: Vec<(usize, Bus)> = Vec::new();
|
||||
let mut scratch = vec![0f32; n];
|
||||
// input map / mono fold (tracks)
|
||||
// input map / mono fold (tracks; stereo-wide strips only)
|
||||
let mono = tr.channels == AudioChannels::Mono;
|
||||
if s.kind != Kind::Master {
|
||||
let [l, r] = &mut buf;
|
||||
let layout = source_layout(tr, s.width);
|
||||
if s.kind != Kind::Master && buf.len() == 2 {
|
||||
let (l, r) = buf.split_at_mut(1);
|
||||
let (l, r) = (&mut l[0], &mut r[0]);
|
||||
match tr.mixer.input_map {
|
||||
InputMap::Stereo => {}
|
||||
InputMap::Left => r.copy_from_slice(l),
|
||||
|
|
@ -646,22 +767,24 @@ fn run_strip(
|
|||
run_inserts(&mut st.pre, &s.pre, &mut buf, x0, lat_a, sr);
|
||||
let lat_b = st.in_lat + st.lat_pre;
|
||||
let c_b = x0 - lat_b as i64;
|
||||
let send = |tap: Tap, buf: &Stereo, c0: i64, scratch: &mut [f32], st: &mut StripState, outs: &mut Vec<(usize, Stereo)>| {
|
||||
let send = |tap: Tap, buf: &Bus, c0: i64, scratch: &mut [f32], st: &mut StripState, outs: &mut Vec<(usize, Bus)>| {
|
||||
for (k, (ri, r)) in my_routes.iter().enumerate() {
|
||||
if r.tap != tap {
|
||||
continue;
|
||||
}
|
||||
let tw = plan.strips[r.target].width;
|
||||
let mut sig = buf.clone();
|
||||
if let Some(si) = r.send {
|
||||
let snd = &tr.mixer.sends[si];
|
||||
if snd.muted {
|
||||
sig = silent(n);
|
||||
sig = silent(tw, n);
|
||||
} else {
|
||||
let lane = Lane::new(tr, id, &send_lane(si), ovs);
|
||||
let g = lane.fill(c0, sr, scratch, db_gain);
|
||||
apply_gain(&mut sig, g, scratch);
|
||||
sig = convert_width(&sig, layout, tw);
|
||||
let (gl, gr) = balance((snd.pan / 100.0) as f32);
|
||||
if gl != 1.0 || gr != 1.0 {
|
||||
if tw == 2 && (gl != 1.0 || gr != 1.0) {
|
||||
sig[0].iter_mut().for_each(|x| *x *= gl);
|
||||
sig[1].iter_mut().for_each(|x| *x *= gr);
|
||||
}
|
||||
|
|
@ -681,10 +804,7 @@ fn run_strip(
|
|||
let vol = Lane::new(tr, id, LANE_VOLUME, ovs);
|
||||
let g = vol.fill(c_b, sr, &mut scratch, db_gain);
|
||||
apply_gain(&mut buf, g, &scratch);
|
||||
let mut meter = [0f32; 2];
|
||||
for (c, m) in meter.iter_mut().enumerate() {
|
||||
*m = buf[c][skip.min(n)..].iter().fold(0f32, |a, x| a.max(x.abs()));
|
||||
}
|
||||
let meter: Vec<f32> = buf.iter().map(|c| c[skip.min(n)..].iter().fold(0f32, |a, x| a.max(x.abs()))).collect();
|
||||
run_inserts(&mut st.post, &s.post, &mut buf, x0, lat_b, sr);
|
||||
let lat_c = lat_b + st.lat_post;
|
||||
let c_c = x0 - lat_c as i64;
|
||||
|
|
@ -692,14 +812,23 @@ fn run_strip(
|
|||
if s.kind == Kind::Master {
|
||||
return StripOut { routes: outs, meter, out: Some(buf) };
|
||||
}
|
||||
let pan = Lane::new(tr, id, LANE_PAN, ovs);
|
||||
apply_pan(&mut buf, &pan, c_c, sr, if mono { pan_law_mono } else { balance }, &mut scratch);
|
||||
let out_w = my_routes.iter().find(|(_, r)| r.tap == Tap::Out).map(|(_, r)| plan.strips[r.target].width).unwrap_or(2);
|
||||
if out_w == 6 {
|
||||
buf = pan51(&buf, layout, tr, id, ovs, c_c, sr);
|
||||
} else {
|
||||
if buf.len() != 2 {
|
||||
buf = convert_width(&buf, layout, 2);
|
||||
}
|
||||
let pan = Lane::new(tr, id, LANE_PAN, ovs);
|
||||
apply_pan(&mut buf, &pan, c_c, sr, if mono { pan_law_mono } else { balance }, &mut scratch);
|
||||
}
|
||||
send(Tap::Out, &buf, c_c, &mut scratch, st, &mut outs);
|
||||
StripOut { routes: outs, meter, out: None }
|
||||
}
|
||||
|
||||
/// Mix `frames` stereo samples of the sequence starting at sample `start` through the mixer graph.
|
||||
/// `live` adds the UI's held controls and receives the meters.
|
||||
/// Mix `frames` samples of the sequence starting at sample `start` through the mixer graph, at the
|
||||
/// Mix's width (2 channels, or 6 in L, R, C, LFE, Ls, Rs order for a 5.1 Mix). `live` adds the UI's
|
||||
/// held controls and receives the meters.
|
||||
pub fn mix_graph(project: &Project, seq: &Sequence, start: i64, frames: usize, sources: &dyn SourceProvider, live: Option<&LiveMix>) -> AudioBuffer {
|
||||
let sr = seq.settings.sample_rate.max(1);
|
||||
let ovs = live.filter(|l| l.is_active()).map(LiveMix::overrides).unwrap_or_default();
|
||||
|
|
@ -721,8 +850,8 @@ pub fn mix_graph(project: &Project, seq: &Sequence, start: i64, frames: usize, s
|
|||
let x0 = start + lat - skip as i64;
|
||||
let total = skip + frames;
|
||||
let ns = plan.strips.len();
|
||||
let mut bus: Vec<Option<Stereo>> = (0..ns).map(|_| None).collect();
|
||||
let mut meters: Vec<(TrackId, [f32; 2])> = Vec::new();
|
||||
let mut bus: Vec<Option<Bus>> = (0..ns).map(|_| None).collect();
|
||||
let mut meters: Vec<(TrackId, Vec<f32>)> = Vec::new();
|
||||
let range = filmcraft_time::TimeRange::from_bounds(Tick::from_units(x0, sr as i64), Tick::from_units(x0 + total as i64, sr as i64));
|
||||
// tracks: independent, in parallel
|
||||
let ntr = plan.strips.iter().take_while(|s| s.kind == Kind::Track).count();
|
||||
|
|
@ -737,26 +866,26 @@ pub fn mix_graph(project: &Project, seq: &Sequence, start: i64, frames: usize, s
|
|||
return None;
|
||||
}
|
||||
let has_clips = s.track.items.iter().any(|i| i.enabled && i.range().overlaps(&range));
|
||||
if !has_clips && s.pre.is_empty() && s.post.is_empty() && ss.delays.iter().all(|d| d.buf.is_empty()) {
|
||||
if !has_clips && s.pre.is_empty() && s.post.is_empty() && ss.delays.iter().all(DelayLine::is_empty) {
|
||||
return None;
|
||||
}
|
||||
let input = if has_clips {
|
||||
let b = track_input(project, &s.track, x0, total, sr, sources);
|
||||
let mut it = b.channels.into_iter();
|
||||
[it.next().unwrap_or_else(|| vec![0.0; total]), it.next().unwrap_or_else(|| vec![0.0; total])]
|
||||
let mut v = track_input_live(project, &s.track, x0, total, sr, sources, &ovs).channels;
|
||||
v.resize(s.width, vec![0.0; total]);
|
||||
v
|
||||
} else {
|
||||
silent(total)
|
||||
silent(s.width, total)
|
||||
};
|
||||
Some(run_strip(&plan, si, ss, input, x0, skip, sr, &ovs))
|
||||
})
|
||||
.collect();
|
||||
let deliver = |o: StripOut, si: usize, bus: &mut Vec<Option<Stereo>>, meters: &mut Vec<(TrackId, [f32; 2])>| {
|
||||
let deliver = |o: StripOut, si: usize, bus: &mut Vec<Option<Bus>>, meters: &mut Vec<(TrackId, Vec<f32>)>| {
|
||||
meters.push((plan.strips[si].track.id, o.meter));
|
||||
for (ri, sig) in o.routes {
|
||||
let t = plan.routes[ri].target;
|
||||
let b = bus[t].get_or_insert_with(|| silent(total));
|
||||
for c in 0..2 {
|
||||
for (d, s) in b[c].iter_mut().zip(&sig[c]) {
|
||||
let b = bus[t].get_or_insert_with(|| silent(plan.strips[t].width, total));
|
||||
for (bc, sc) in b.iter_mut().zip(&sig) {
|
||||
for (d, s) in bc.iter_mut().zip(sc) {
|
||||
*d += *s;
|
||||
}
|
||||
}
|
||||
|
|
@ -769,7 +898,7 @@ pub fn mix_graph(project: &Project, seq: &Sequence, start: i64, frames: usize, s
|
|||
}
|
||||
}
|
||||
// submixes in order, then the Mix
|
||||
let mut out = silent(total);
|
||||
let mut out = silent(plan.strips[ns - 1].width, total);
|
||||
for (k, ss) in rest.iter_mut().enumerate() {
|
||||
let si = ntr + k;
|
||||
let s = &plan.strips[si];
|
||||
|
|
@ -777,7 +906,7 @@ pub fn mix_graph(project: &Project, seq: &Sequence, start: i64, frames: usize, s
|
|||
ss.delays.iter_mut().for_each(DelayLine::clear);
|
||||
continue;
|
||||
}
|
||||
let input = bus[si].take().unwrap_or_else(|| silent(total));
|
||||
let input = bus[si].take().unwrap_or_else(|| silent(s.width, total));
|
||||
let o = run_strip(&plan, si, ss, input, x0, skip, sr, &ovs);
|
||||
if let Some(fin) = deliver(o, si, &mut bus, &mut meters) {
|
||||
out = fin;
|
||||
|
|
@ -798,6 +927,5 @@ pub fn mix_graph(project: &Project, seq: &Sequence, start: i64, frames: usize, s
|
|||
if let Some(l) = live {
|
||||
l.post_meters(&meters);
|
||||
}
|
||||
let [l, r] = out;
|
||||
AudioBuffer { sample_rate: sr, channels: vec![l[skip..].to_vec(), r[skip..].to_vec()] }
|
||||
AudioBuffer { sample_rate: sr, channels: out.into_iter().map(|c| c[skip..].to_vec()).collect() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const SR: i64 = 48_000;
|
|||
struct FnSource {
|
||||
info: MediaInfo,
|
||||
f: fn(i64, usize) -> f32,
|
||||
ch: usize,
|
||||
}
|
||||
|
||||
impl MediaSource for FnSource {
|
||||
|
|
@ -31,7 +32,7 @@ impl MediaSource for FnSource {
|
|||
}
|
||||
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
|
||||
let f = self.f;
|
||||
Ok(AudioBuffer { sample_rate, channels: (0..2).map(|c| (0..frames).map(|i| f(start + i as i64, c)).collect()).collect() })
|
||||
Ok(AudioBuffer { sample_rate, channels: (0..self.ch).map(|c| (0..frames).map(|i| f(start + i as i64, c)).collect()).collect() })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +45,10 @@ struct Rig {
|
|||
impl Rig {
|
||||
/// A sequence with `n` audio tracks, each holding one 10 s clip of its signal.
|
||||
fn new(signals: &[fn(i64, usize) -> f32]) -> Rig {
|
||||
Self::with_channels(signals, 2)
|
||||
}
|
||||
/// Like [`Rig::new`] with `ch`-channel sources.
|
||||
fn with_channels(signals: &[fn(i64, usize) -> f32], ch: usize) -> Rig {
|
||||
let mut p = Project::new("mix");
|
||||
let seq = p.new_sequence("s", SequenceSettings::default(), 0, signals.len(), None);
|
||||
let mut map = SourceMap::default();
|
||||
|
|
@ -53,7 +58,7 @@ impl Rig {
|
|||
kind: MediaKind::AudioOnly,
|
||||
duration: Tick(10 * TICKS_PER_SECOND),
|
||||
video: None,
|
||||
audio: Some(AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "test".into(), bits_per_sample: Some(32) }),
|
||||
audio: Some(AudioStreamInfo { sample_rate: 48_000, channels: ch as u32, codec: "test".into(), bits_per_sample: Some(32) }),
|
||||
container: "test".into(),
|
||||
start_timecode: None,
|
||||
file_size: None,
|
||||
|
|
@ -74,7 +79,7 @@ impl Rig {
|
|||
}),
|
||||
None,
|
||||
);
|
||||
map.0.insert(id, Arc::new(FnSource { info, f: *f }));
|
||||
map.0.insert(id, Arc::new(FnSource { info, f: *f, ch }));
|
||||
let ti = p.make_track_item(id, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, Tick(10 * TICKS_PER_SECOND)), FrameRate::FPS_24).unwrap();
|
||||
p.sequence_mut(seq).unwrap().audio_tracks[k].items.push(ti);
|
||||
}
|
||||
|
|
@ -471,3 +476,135 @@ fn audio_transition_curves() {
|
|||
assert!(a.abs() < 1e-6 && close(b, 1.0, 1e-6), "{k} ends on B");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- 5.1
|
||||
|
||||
/// Distinct DC level per source channel: 0.1, 0.2, … (L, R, C, LFE, Ls, Rs for 6-channel sources).
|
||||
fn chan_dc(_: i64, c: usize) -> f32 {
|
||||
(c + 1) as f32 * 0.1
|
||||
}
|
||||
|
||||
fn set_master_51(r: &mut Rig) {
|
||||
r.p.sequence_mut(r.seq).unwrap().settings.audio_master = AudioChannels::Surround51;
|
||||
}
|
||||
|
||||
fn mix_all(r: &Rig, start: i64, n: usize) -> Vec<Vec<f32>> {
|
||||
mix_graph(&r.p, r.p.sequence(r.seq).unwrap(), start, n, &r.map, None).channels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_and_mono_tracks_into_a_51_mix_use_the_51_panner() {
|
||||
use filmcraft_project::mixer::{LANE_PAN51_CENTER, LANE_PAN51_LFE, LANE_PAN51_X, LANE_PAN51_Y};
|
||||
let mut r = Rig::new(&[chan_dc]);
|
||||
set_master_51(&mut r);
|
||||
// stereo track at the default puck (front centre): L → L, R → R, nothing else
|
||||
let m = mix_all(&r, 0, 256);
|
||||
assert_eq!(m.len(), 6);
|
||||
let at = |m: &Vec<Vec<f32>>, c: usize| m[c][100];
|
||||
assert!(close(at(&m, 0), 0.1, 1e-6) && close(at(&m, 1), 0.2, 1e-6), "{:?}", m.iter().map(|c| c[100]).collect::<Vec<_>>());
|
||||
for c in 2..6 {
|
||||
assert!(at(&m, c).abs() < 1e-6, "channel {c} = {}", at(&m, c));
|
||||
}
|
||||
// LFE knob: mean of the source channels
|
||||
r.track(0).set_lane_static(LANE_PAN51_LFE, 0.0);
|
||||
let m = mix_all(&r, 0, 256);
|
||||
assert!(close(at(&m, 3), 0.15, 1e-6));
|
||||
r.track(0).set_lane_static(LANE_PAN51_LFE, -96.0);
|
||||
// mono track: folded, then a point source; front centre at 100 % centre = C only
|
||||
r.track(0).channels = AudioChannels::Mono;
|
||||
let m = mix_all(&r, 0, 256);
|
||||
assert!(close(at(&m, 2), 0.15, 1e-6) && at(&m, 0).abs() < 1e-6 && at(&m, 4).abs() < 1e-6);
|
||||
// hard rear right
|
||||
r.track(0).set_lane_static(LANE_PAN51_X, 100.0);
|
||||
r.track(0).set_lane_static(LANE_PAN51_Y, -100.0);
|
||||
let m = mix_all(&r, 0, 256);
|
||||
assert!(close(at(&m, 5), 0.15, 1e-6));
|
||||
// room centre, 0 % centre: equal power over L, R, Ls, Rs (−6 dB each)
|
||||
r.track(0).set_lane_static(LANE_PAN51_X, 0.0);
|
||||
r.track(0).set_lane_static(LANE_PAN51_Y, 0.0);
|
||||
r.track(0).set_lane_static(LANE_PAN51_CENTER, 0.0);
|
||||
let m = mix_all(&r, 0, 256);
|
||||
for c in [0, 1, 4, 5] {
|
||||
assert!(close(at(&m, c), 0.075, 1e-6), "channel {c}");
|
||||
}
|
||||
let power: f32 = (0..6).map(|c| at(&m, c) * at(&m, c)).sum();
|
||||
assert!(close(power, 0.15 * 0.15, 1e-6), "equal power: {power}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_51_track_passes_through_a_51_mix_and_folds_into_stereo() {
|
||||
let mut r = Rig::with_channels(&[chan_dc], 6);
|
||||
r.track(0).channels = AudioChannels::Surround51;
|
||||
set_master_51(&mut r);
|
||||
let m = mix_all(&r, 0, 256);
|
||||
for (c, ch) in m.iter().enumerate() {
|
||||
assert!(close(ch[17], (c + 1) as f32 * 0.1, 1e-6), "channel {c} = {}", ch[17]);
|
||||
}
|
||||
// the same track into a stereo Mix: ITU-R BS.775 downmix (LFE omitted)
|
||||
r.p.sequence_mut(r.seq).unwrap().settings.audio_master = AudioChannels::Stereo;
|
||||
let k = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let st = mix_all(&r, 0, 256);
|
||||
assert_eq!(st.len(), 2);
|
||||
assert!(close(st[0][9], 0.1 + k * 0.3 + k * 0.5, 1e-6) && close(st[1][9], 0.2 + k * 0.3 + k * 0.6, 1e-6));
|
||||
// mix_sequence of a 5.1 Mix is the BS.775 stereo fold of the 6-channel mix
|
||||
set_master_51(&mut r);
|
||||
let folded = crate::audio::mix_sequence(&r.p, r.p.sequence(r.seq).unwrap(), 0, 256, &r.map);
|
||||
assert_eq!(folded.channels.len(), 2);
|
||||
assert!(close(folded.channels[0][9], st[0][9], 1e-6) && close(folded.channels[1][9], st[1][9], 1e-6));
|
||||
// Front Only mixdown for playback devices drops the surrounds
|
||||
let front = crate::audio::mix_sequence_layout(
|
||||
&r.p,
|
||||
r.p.sequence(r.seq).unwrap(),
|
||||
0,
|
||||
256,
|
||||
&r.map,
|
||||
filmcraft_audio_dsp::channels::Layout::Stereo,
|
||||
filmcraft_audio_dsp::channels::Mixdown::Front,
|
||||
);
|
||||
assert!(close(front.channels[0][9], 0.1 + k * 0.3, 1e-6));
|
||||
// meters: one per channel of the strip
|
||||
let live = LiveMix::new();
|
||||
mix_graph(&r.p, r.p.sequence(r.seq).unwrap(), 0, 256, &r.map, Some(&live));
|
||||
let meters = live.take_meters();
|
||||
assert_eq!(meters.get(&crate::mixer::MASTER).map(Vec::len), Some(6));
|
||||
assert!(close(meters[&crate::mixer::MASTER][5], 0.6, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_track_through_a_51_submix_into_a_stereo_mix() {
|
||||
let mut r = Rig::new(&[chan_dc]);
|
||||
let sub = r.add_submix("5.1 bus");
|
||||
r.submix(sub).channels = AudioChannels::Surround51;
|
||||
r.track(0).mixer.output = Some(sub);
|
||||
// puck hard left at the front: source L on L, source R at front centre (C); folded into stereo
|
||||
// L = L + k·C, R = k·C
|
||||
r.track(0).set_lane_static(filmcraft_project::mixer::LANE_PAN51_X, -100.0);
|
||||
let st = mix_all(&r, 0, 128);
|
||||
let k = std::f32::consts::FRAC_1_SQRT_2;
|
||||
assert!(close(st[0][50], 0.1 + k * 0.2, 1e-6) && close(st[1][50], k * 0.2, 1e-6), "{} {}", st[0][50], st[1][50]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan51_automation_is_block_invariant() {
|
||||
use filmcraft_project::mixer::LANE_PAN51_X;
|
||||
let mut r = Rig::new(&[noise]);
|
||||
set_master_51(&mut r);
|
||||
let p = r.track(0).lane_mut(LANE_PAN51_X).unwrap();
|
||||
p.keyframes.push(kf(0, -100.0));
|
||||
p.keyframes.push(kf(12_000, 100.0));
|
||||
let whole = mix_all(&r, 0, 12_000);
|
||||
let mut cut: Vec<Vec<f32>> = vec![Vec::new(); 6];
|
||||
let mut pos = 0i64;
|
||||
for m in [97usize, 1000, 4000, 6903] {
|
||||
let b = mix_all(&r, pos, m);
|
||||
for (c, ch) in b.into_iter().enumerate() {
|
||||
cut[c].extend(ch);
|
||||
}
|
||||
pos += m as i64;
|
||||
}
|
||||
assert_eq!(whole, cut);
|
||||
// the puck moved from left to right: L dominates early, R late
|
||||
let energy = |c: usize, a: usize, b: usize| whole[c][a..b].iter().map(|x| x * x).sum::<f32>();
|
||||
assert!(energy(0, 0, 2000) > 5.0 * energy(1, 0, 2000));
|
||||
assert!(energy(1, 10_000, 12_000) > 5.0 * energy(0, 10_000, 12_000));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
//! Playback of remixed clips (Clip ▸ Remix): maps the clip's timeline samples through its remix
|
||||
//! plan to media samples, crossfading at the joints.
|
||||
//!
|
||||
//! A remixed clip carries a hidden effect instance [`EFFECT`] (no effect definition, so the
|
||||
//! Effects panel, Effect Controls, the audio-effect chain and Paste Attributes ignore it). Its
|
||||
//! parameters hold the Remix settings and the plan:
|
||||
//!
|
||||
//! | param | value |
|
||||
//! |---|---|
|
||||
//! | `target` | target duration (ticks, as a float) |
|
||||
//! | `segments`, `variations` | the Remix sliders (0 … 100) |
|
||||
//! | `original` | the clip duration before the remix (ticks), restored by Revert Remix |
|
||||
//! | `plan` | text `xfade;src,len;src,len;…` in ticks: crossfade length, then each piece's media start and length |
|
||||
//!
|
||||
//! The plan is computed by the engine (`clip.remix`) with [`filmcraft_audio_dsp::remix`]; playback
|
||||
//! only reads it, so rendering a remixed clip costs no analysis.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use filmcraft_audio_dsp::remix::{self as dsp, Piece};
|
||||
use filmcraft_frame::AudioBuffer;
|
||||
use filmcraft_project::{EffectInstance, Param, ParamValue, TrackItem};
|
||||
use filmcraft_time::Tick;
|
||||
|
||||
/// Effect id of the hidden remix state.
|
||||
pub const EFFECT: &str = "remix";
|
||||
|
||||
/// One piece of a remix in ticks: `len` ticks of media from media time `src`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TickPiece {
|
||||
pub src: Tick,
|
||||
pub len: Tick,
|
||||
}
|
||||
|
||||
/// The remix state of a clip.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Remix {
|
||||
pub target: Tick,
|
||||
pub segments: f64,
|
||||
pub variations: f64,
|
||||
/// Clip duration before the remix.
|
||||
pub original: Tick,
|
||||
/// Crossfade at each joint.
|
||||
pub xfade: Tick,
|
||||
pub pieces: Vec<TickPiece>,
|
||||
}
|
||||
|
||||
impl Remix {
|
||||
/// Output length of the plan.
|
||||
pub fn len(&self) -> Tick {
|
||||
Tick(self.pieces.iter().map(|p| p.len.0).sum())
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.pieces.is_empty()
|
||||
}
|
||||
|
||||
/// The remix of `item`, if it has one.
|
||||
pub fn of(item: &TrackItem) -> Option<Remix> {
|
||||
let e = item.effects.iter().find(|e| e.effect == EFFECT)?;
|
||||
let f = |k: &str| e.param(k).and_then(|p| p.value.as_f64());
|
||||
let plan = match e.param("plan").map(|p| &p.value) {
|
||||
Some(ParamValue::Text(s)) => dsp::Plan::from_text(s),
|
||||
_ => None,
|
||||
};
|
||||
let (xfade, pieces) = match plan {
|
||||
Some(p) => (Tick(p.xfade), p.pieces.iter().map(|p| TickPiece { src: Tick(p.src), len: Tick(p.len) }).collect()),
|
||||
None => (Tick::ZERO, Vec::new()),
|
||||
};
|
||||
Some(Remix {
|
||||
target: Tick(f("target").unwrap_or(0.0) as i64),
|
||||
segments: f("segments").unwrap_or(50.0),
|
||||
variations: f("variations").unwrap_or(50.0),
|
||||
original: Tick(f("original").unwrap_or(item.duration.0 as f64) as i64),
|
||||
xfade,
|
||||
pieces,
|
||||
})
|
||||
}
|
||||
|
||||
/// The hidden effect instance that stores this remix.
|
||||
pub fn to_effect(&self) -> EffectInstance {
|
||||
let plan = dsp::Plan { pieces: self.pieces.iter().map(|p| Piece { src: p.src.0, len: p.len.0 }).collect(), xfade: self.xfade.0 };
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert("target".to_string(), Param::new(ParamValue::Float(self.target.0 as f64)));
|
||||
params.insert("segments".to_string(), Param::new(ParamValue::Float(self.segments)));
|
||||
params.insert("variations".to_string(), Param::new(ParamValue::Float(self.variations)));
|
||||
params.insert("original".to_string(), Param::new(ParamValue::Float(self.original.0 as f64)));
|
||||
params.insert("plan".to_string(), Param::new(ParamValue::Text(if self.pieces.is_empty() { String::new() } else { plan.to_text() })));
|
||||
EffectInstance { effect: EFFECT.to_string(), enabled: true, params, masks: Vec::new(), post_fader: false, essential: false }
|
||||
}
|
||||
|
||||
/// Store this remix on `item` (replacing an earlier one).
|
||||
pub fn store(&self, item: &mut TrackItem) {
|
||||
let e = self.to_effect();
|
||||
match item.effects.iter_mut().find(|x| x.effect == EFFECT) {
|
||||
Some(x) => *x = e,
|
||||
None => item.effects.push(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The plan in samples at `sr` (piece boundaries are floored on the timeline, so consecutive
|
||||
/// pieces tile the clip exactly at every rate).
|
||||
pub fn plan_at(&self, sr: u32) -> dsp::Plan {
|
||||
let r = sr as i64;
|
||||
let mut pieces = Vec::with_capacity(self.pieces.len());
|
||||
let mut d = Tick::ZERO;
|
||||
for p in &self.pieces {
|
||||
let a = d.to_units_floor(r);
|
||||
d += p.len;
|
||||
pieces.push(Piece { src: p.src.to_units_floor(r), len: d.to_units_floor(r) - a });
|
||||
}
|
||||
dsp::Plan { pieces, xfade: self.xfade.to_units_floor(r) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the remix state from `item`; returns it.
|
||||
pub fn take(item: &mut TrackItem) -> Option<Remix> {
|
||||
let r = Remix::of(item)?;
|
||||
item.effects.retain(|e| e.effect != EFFECT);
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// Whether `item` is remixed (has a plan that playback follows).
|
||||
pub fn is_remixed(item: &TrackItem) -> bool {
|
||||
item.effects.iter().any(|e| e.effect == EFFECT && e.enabled)
|
||||
}
|
||||
|
||||
/// Audio of a remixed clip for clip-relative timeline samples `[rel0, rel0 + n)` at rate `sr`,
|
||||
/// reading the media through `read(media_sample, len)`. `None` when the clip is not remixed (the
|
||||
/// caller then reads the media linearly). Speed and reverse do not apply to remixed clips.
|
||||
pub fn read(item: &TrackItem, read: &dyn Fn(i64, usize) -> Option<AudioBuffer>, rel0: i64, n: usize, sr: u32) -> Option<AudioBuffer> {
|
||||
if !is_remixed(item) {
|
||||
return None;
|
||||
}
|
||||
let rm = Remix::of(item)?;
|
||||
if rm.pieces.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let plan = rm.plan_at(sr);
|
||||
let channels = read(plan.pieces[0].src, 1).map(|b| b.channel_count()).unwrap_or(2).max(1);
|
||||
let mut rd = |s: i64, len: usize| -> Vec<Vec<f32>> {
|
||||
let mut b = read(s, len).map(|b| b.channels).unwrap_or_default();
|
||||
b.resize(channels, Vec::new());
|
||||
for c in b.iter_mut() {
|
||||
c.resize(len, 0.0);
|
||||
}
|
||||
b
|
||||
};
|
||||
let out = dsp::render(&plan, rel0, n, channels, &mut rd);
|
||||
Some(AudioBuffer { sample_rate: sr, channels: out })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item() -> TrackItem {
|
||||
let d = Tick::from_seconds_f64(1.0).0;
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": 1, "item": 2, "name": "m", "label": "Violet", "start": 0, "duration": d, "source_in": 0,
|
||||
"speed": 1.0, "enabled": true, "effects": []
|
||||
}))
|
||||
.expect("track item")
|
||||
}
|
||||
|
||||
fn source(m: i64, len: usize) -> Option<AudioBuffer> {
|
||||
// a ramp: sample value = media sample index / 1e5 (silence outside 0 … 48 000)
|
||||
let ch: Vec<f32> = (0..len as i64).map(|k| if (0..48_000).contains(&(m + k)) { (m + k) as f32 / 1e5 } else { 0.0 }).collect();
|
||||
Some(AudioBuffer { sample_rate: 48_000, channels: vec![ch.clone(), ch] })
|
||||
}
|
||||
|
||||
fn remix() -> Remix {
|
||||
let s = |x: f64| Tick::from_seconds_f64(x);
|
||||
Remix {
|
||||
target: s(0.6),
|
||||
segments: 50.0,
|
||||
variations: 50.0,
|
||||
original: s(1.0),
|
||||
xfade: s(0.002),
|
||||
pieces: vec![TickPiece { src: s(0.0), len: s(0.25) }, TickPiece { src: s(0.65), len: s(0.35) }],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_remixed_reads_nothing() {
|
||||
assert!(read(&item(), &source, 0, 10, 48_000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_roundtrips_through_the_hidden_effect() {
|
||||
let mut it = item();
|
||||
let r = remix();
|
||||
r.store(&mut it);
|
||||
r.store(&mut it);
|
||||
assert_eq!(it.effects.len(), 1);
|
||||
assert_eq!(Remix::of(&it), Some(r.clone()));
|
||||
assert!(it.effects[0].def().is_none(), "no definition: hidden from panels and the fx chain");
|
||||
assert_eq!(take(&mut it), Some(r));
|
||||
assert!(it.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plays_the_pieces_and_ignores_request_cuts() {
|
||||
let mut it = item();
|
||||
remix().store(&mut it);
|
||||
let total = (0.6 * 48_000.0) as usize;
|
||||
let whole = read(&it, &source, 0, total, 48_000).unwrap();
|
||||
// away from the joint (12 000) the output is the media ramp of each piece
|
||||
assert_eq!(whole.channels[0][100], 100.0 / 1e5);
|
||||
assert_eq!(whole.channels[0][11_000], 11_000.0 / 1e5);
|
||||
assert_eq!(whole.channels[0][13_000], (31_200 + 1_000) as f32 / 1e5);
|
||||
assert_eq!(whole.channels[1][20_000], (31_200 + 8_000) as f32 / 1e5);
|
||||
let mut cut: Vec<f32> = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
let mut step = 3usize;
|
||||
while pos < total {
|
||||
let n = step.min(total - pos);
|
||||
cut.extend(read(&it, &source, pos as i64, n, 48_000).unwrap().channels[0].iter());
|
||||
pos += n;
|
||||
step = step * 5 % 1999 + 1;
|
||||
}
|
||||
assert_eq!(cut, whole.channels[0]);
|
||||
// other rates tile the same plan
|
||||
let p = remix().plan_at(44_100);
|
||||
assert_eq!(p.len(), (0.6 * 44_100.0) as i64);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ pub enum Icon {
|
|||
Ripple,
|
||||
Rolling,
|
||||
RateStretch,
|
||||
Remix,
|
||||
Razor,
|
||||
Slip,
|
||||
Slide,
|
||||
|
|
@ -192,6 +193,17 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
|||
pen.circle(8.0, 4.0, 1.4);
|
||||
pen.circle(8.0, 12.0, 1.4);
|
||||
}
|
||||
Remix => {
|
||||
// original: a waveform cut in three blocks that swap places (two arrows over a gap)
|
||||
pen.line(&[(2.0, 8.0), (3.0, 5.5), (4.0, 10.5), (5.0, 7.0)]);
|
||||
pen.line(&[(11.0, 7.0), (12.0, 10.5), (13.0, 5.5), (14.0, 8.0)]);
|
||||
pen.line(&[(7.0, 3.0), (7.0, 13.0)]);
|
||||
pen.line(&[(9.0, 3.0), (9.0, 13.0)]);
|
||||
pen.line(&[(4.0, 2.5), (12.0, 2.5)]);
|
||||
pen.fill(&[(12.5, 2.5), (10.5, 1.0), (10.5, 4.0)]);
|
||||
pen.line(&[(12.0, 13.5), (4.0, 13.5)]);
|
||||
pen.fill(&[(3.5, 13.5), (5.5, 12.0), (5.5, 14.8)]);
|
||||
}
|
||||
Razor => {
|
||||
pen.closed(&[(2.5, 5.0), (13.5, 5.0), (13.5, 11.0), (2.5, 11.0)]);
|
||||
pen.circle(8.0, 8.0, 1.3);
|
||||
|
|
|
|||
|
|
@ -417,6 +417,8 @@ impl FilmcraftApp {
|
|||
}
|
||||
// Multi-Camera view: playing records live cuts (keys 1–9 / clicking angles)
|
||||
panels::multicam::on_play(self);
|
||||
// voice-over: the capture starts with the audio clock
|
||||
panels::voiceover::on_play(self);
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
|
|
@ -435,6 +437,7 @@ impl FilmcraftApp {
|
|||
}
|
||||
}
|
||||
panels::multicam::on_stop(self);
|
||||
panels::voiceover::on_stop(self);
|
||||
}
|
||||
|
||||
fn start_audio(&mut self) {
|
||||
|
|
@ -452,9 +455,12 @@ impl FilmcraftApp {
|
|||
let start_tick = self.session.playhead();
|
||||
// Settings ▸ Audio Hardware ▸ Output Mapping
|
||||
let map = [self.session.prefs.audio_hardware.map_left, self.session.prefs.audio_hardware.map_right];
|
||||
// Preferences ▸ Audio ▸ 5.1 Mixdown Type: how a 5.1 Mix plays on a stereo device
|
||||
let mixdown = filmcraft_audio_dsp::channels::Mixdown::from_id(&self.session.prefs.audio.mixdown_type).unwrap_or_default();
|
||||
let Some(a) = self.audio.as_mut() else { return };
|
||||
let sr = a.sample_rate();
|
||||
let mut cursor = start_tick.to_units_floor(sr as i64);
|
||||
let cues = panels::voiceover::cues(&self.session, sr);
|
||||
previews.live.publish_project(project.clone());
|
||||
let fill = Box::new(move |buf: &mut [f32], ch: usize| {
|
||||
// the newest project snapshot: mixer moves and other edits are heard while playing
|
||||
|
|
@ -463,22 +469,35 @@ impl FilmcraftApp {
|
|||
let n = buf.len() / ch.max(1);
|
||||
// Mix at the sequence rate; convert when the device rate differs (nearest sample).
|
||||
let seq_sr = seq.settings.sample_rate;
|
||||
// a 5.1 Mix plays as six channels (L, R, C, LFE, Ls, Rs) on a device with at least six
|
||||
use filmcraft_audio_dsp::channels::Layout;
|
||||
let layout = if ch >= 6 && seq.settings.audio_master == filmcraft_project::AudioChannels::Surround51 { Layout::Surround51 } else { Layout::Stereo };
|
||||
let mix = if seq_sr == sr {
|
||||
previews.mix(&project, seq_id, cursor, n, &provider)
|
||||
previews.mix_layout(&project, seq_id, cursor, n, &provider, layout, mixdown)
|
||||
} else {
|
||||
let s0 = (cursor as i128 * seq_sr as i128 / sr as i128) as i64;
|
||||
let m = n * seq_sr as usize / sr as usize + 2;
|
||||
let b = previews.mix(&project, seq_id, s0, m, &provider);
|
||||
let mut out = filmcraft_frame::AudioBuffer::silence(sr, 2, n);
|
||||
for c in 0..2 {
|
||||
for i in 0..n {
|
||||
let b = previews.mix_layout(&project, seq_id, s0, m, &provider, layout, mixdown);
|
||||
let mut out = filmcraft_frame::AudioBuffer::silence(sr, b.channels.len(), n);
|
||||
for (o, c) in out.channels.iter_mut().zip(&b.channels) {
|
||||
for (i, x) in o.iter_mut().enumerate() {
|
||||
let j = (i * seq_sr as usize / sr as usize).min(m - 1);
|
||||
out.channels[c][i] = b.channels[c][j];
|
||||
*x = c[j];
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
filmcraft_engine::settings::map_output(&mix.channels[0], &mix.channels[1.min(mix.channels.len() - 1)], buf, ch, map);
|
||||
if layout == Layout::Surround51 {
|
||||
buf.fill(0.0);
|
||||
for (i, frame) in buf.chunks_mut(ch).enumerate() {
|
||||
for (c, x) in frame.iter_mut().take(6).enumerate() {
|
||||
*x = mix.channels[c][i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filmcraft_engine::settings::map_output(&mix.channels[0], &mix.channels[1.min(mix.channels.len() - 1)], buf, ch, map);
|
||||
}
|
||||
panels::voiceover::mix_cues(buf, ch, cursor, &cues);
|
||||
cursor += n as i64;
|
||||
});
|
||||
match a.start(fill) {
|
||||
|
|
@ -994,6 +1013,7 @@ impl FilmcraftApp {
|
|||
state::Tool::Ripple => "Drag an edit point to ripple trim; later clips move to keep the gap closed.",
|
||||
state::Tool::Rolling => "Drag an edit point to roll it: the out of one clip and the in of the next move together.",
|
||||
state::Tool::RateStretch => "Drag an edge to change the clip's speed so it fills the new duration.",
|
||||
state::Tool::Remix => "Drag the edge of a music clip to remix it to the new duration at musically matching beats.",
|
||||
state::Tool::Razor => "Click to split a clip. Shift-click to split all tracks.",
|
||||
state::Tool::Slip => "Drag a clip to slip its source in/out without moving it.",
|
||||
state::Tool::Slide => "Drag a clip to slide it between its neighbours.",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("view.dynamicAudioWaveforms", "Dynamic Audio Waveforms", ["View"], None),
|
||||
uic!("multicam.toggleView", "Multi-Camera View", ["View"], Some("Shift+0")),
|
||||
uic!("multicam.recordToggle", "Multi-Camera Record On/Off Toggle", [], Some("0")),
|
||||
uic!("voiceover.recordToggle", "Voice-over Record", [], None),
|
||||
uic!("voiceover.settingsDialog", "Voice-Over Record Settings…", [], None),
|
||||
uic!("view.theme.dark", "Darkest", ["View", "Appearance"], None),
|
||||
uic!("view.theme.medium", "Medium", ["View", "Appearance"], None),
|
||||
uic!("view.theme.light", "Light", ["View", "Appearance"], None),
|
||||
|
|
@ -97,6 +99,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
uic!("tool.ripple", "Ripple Edit Tool", [], Some("B")),
|
||||
uic!("tool.rolling", "Rolling Edit Tool", [], Some("N")),
|
||||
uic!("tool.rateStretch", "Rate Stretch Tool", [], Some("R")),
|
||||
uic!("tool.remix", "Remix Tool", [], None),
|
||||
uic!("tool.razor", "Razor Tool", [], Some("C")),
|
||||
uic!("tool.slip", "Slip Tool", [], Some("Y")),
|
||||
uic!("tool.slide", "Slide Tool", [], Some("U")),
|
||||
|
|
@ -173,6 +176,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
|
|||
}
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = crate::panels::voiceover::route(app, ctx, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = crate::panels::clip_dialogs::route(app, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
|
|
@ -282,6 +288,11 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
|
|||
crate::panels::color_dialogs::open_interpret(app, ¶ms);
|
||||
return Ok(json!({"dialog": "interpretFootage"}));
|
||||
}
|
||||
// Remix Properties… from the menu opens the dialog; with params it applies directly.
|
||||
"clip.remix.properties" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
|
||||
return crate::panels::remix::open(app, ctx);
|
||||
}
|
||||
"sequence.deleteTracks" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
|
||||
app.ui.delete_tracks = Default::default();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
|||
crate::panels::audio_fx_editor::show(app, ctx);
|
||||
crate::panels::multicam::show_dialog(app, ctx);
|
||||
crate::panels::monitor_view::dialogs(app, ctx);
|
||||
crate::panels::voiceover::show(app, ctx);
|
||||
crate::panels::remix::show(app, ctx);
|
||||
let Some(d) = app.dialog else { return };
|
||||
if let Some(still_open) = crate::panels::file_dialogs::show(app, ctx, d) {
|
||||
if !still_open && app.dialog == Some(d) {
|
||||
|
|
|
|||
|
|
@ -705,9 +705,13 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
|||
}
|
||||
});
|
||||
row(ui, t, "Channels", |ui| {
|
||||
let cur = if s.audio.channels == 1 { "Mono" } else { "Stereo" };
|
||||
if let Some(i) = combo(ui, reg, "export.audio.channels", cur, &opts(&["Mono", "Stereo"]), 120.0) {
|
||||
s.audio.channels = i as u32 + 1;
|
||||
let cur = match s.audio.channels {
|
||||
1 => "Mono",
|
||||
6 => "5.1",
|
||||
_ => "Stereo",
|
||||
};
|
||||
if let Some(i) = combo(ui, reg, "export.audio.channels", cur, &opts(&["Mono", "Stereo", "5.1"]), 120.0) {
|
||||
s.audio.channels = [1, 2, 6][i.min(2)];
|
||||
}
|
||||
});
|
||||
if s.audio_codec() == AudioCodec::Aac {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,10 @@ fn lufs_text(v: f64) -> String {
|
|||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
// levels of the Mix as it plays (fast attack, 20 dB/s release, peak hold)
|
||||
let st = super::mixer::poll_meters(app, ui).get(&filmcraft_render::mixer::MASTER.0).copied().unwrap_or([-90.0; 4]);
|
||||
let st = super::mixer::poll_meters(app, ui).get(&filmcraft_render::mixer::MASTER.0).cloned().unwrap_or_else(|| vec![[-90.0; 2]; 2]);
|
||||
// one bar per channel of the Mix (2, or 6 for a 5.1 Mix)
|
||||
let nch = app.session.active_sequence().map(|q| filmcraft_render::mixer::width_of(q.settings.audio_master)).unwrap_or(2);
|
||||
let names = filmcraft_audio_dsp::channels::Layout::from_channels(nch).names();
|
||||
// Premiere: black meter area, scale 0 … −57 dB in 3 dB steps on the right, "dB" at the foot.
|
||||
// Loudness readout (BS.1770 / EBU R128) under the bars.
|
||||
let lufs_h = if rect.height() > 260.0 { 64.0 } else { 0.0 };
|
||||
|
|
@ -82,10 +85,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
app.auto.add("audioMeters.loudness", lr, &format!("M {} S {} I {} LUFS TP {} dBTP", lufs_text(m), lufs_text(s), lufs_text(i), lufs_text(tp)));
|
||||
}
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, area.max.y + 4.0)), 0.0, egui::Color32::BLACK);
|
||||
let w = ((area.width() - 4.0) / 2.0).max(3.0);
|
||||
for c in 0..2 {
|
||||
let w = ((area.width() - 4.0 * (nch as f32 - 1.0)) / nch as f32).max(3.0);
|
||||
for c in 0..nch {
|
||||
let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 4.0), area.min.y), vec2(w, area.height()));
|
||||
crate::widgets::meter_bar(ui.painter(), r, st[c], st[c + 2], &t);
|
||||
let m = st.get(c).copied().unwrap_or([-90.0; 2]);
|
||||
crate::widgets::meter_bar(ui.painter(), r, m[0], m[1], &t);
|
||||
app.auto.add(&format!("audioMeters.channel.{}", names[c]), r, &format!("{} {:.1} dB", names[c], m[0]));
|
||||
}
|
||||
let mut db = 0;
|
||||
while db >= -57 {
|
||||
|
|
@ -94,9 +99,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
db -= 3;
|
||||
}
|
||||
ui.painter().text(pos2(rect.max.x - 6.0, area.max.y + 10.0), Align2::RIGHT_CENTER, "dB", Tokens::ui(8.5), t.text_dim);
|
||||
for c in 0..2 {
|
||||
for c in 0..nch {
|
||||
let r = Rect::from_center_size(pos2(area.min.x + c as f32 * (w + 4.0) + w / 2.0, area.max.y + 16.0), vec2(14.0, 14.0));
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "S", Tokens::ui(10.0), t.text_dim);
|
||||
let label = if nch == 2 { "S" } else { names[c] };
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(if nch == 2 { 10.0 } else { 8.5 }), t.text_dim);
|
||||
}
|
||||
if app.playback.playing {
|
||||
ui.ctx().request_repaint();
|
||||
|
|
|
|||
|
|
@ -10,13 +10,21 @@
|
|||
//! Automation ids: `mixer.<A1|S1|Mix>.<fader|value|pan|panValue|mode|mute|solo|record|output|input|
|
||||
//! fx.<slot>|send.<slot>|meter|name>`, popup entries below them (`mixer.A1.mode.Touch`…), the
|
||||
//! effects disclosure `mixer.showEffects`, and the footer transport `mixer.transport.<command>`.
|
||||
//! The Clip Mixer uses `clipMixer.A1.<fader|value|pan|mute|solo|keyframe>`.
|
||||
//! The Clip Mixer uses `clipMixer.A1.<fader|value|pan|mute|solo|keyframe|mode>`.
|
||||
//!
|
||||
//! Strips feeding a 5.1 submix or Mix show the 5.1 panner instead of the pan knob: a puck in the
|
||||
//! listening square (`mixer.A1.pan51`, drag; double-click: front centre) with Center % and LFE
|
||||
//! controls (`mixer.A1.pan51.center`, `mixer.A1.pan51.lfe`). Meters show one bar per channel
|
||||
//! (six for 5.1 strips). The panel menu (`mixer.menu`) holds Show/Hide Tracks (`mixer.menu.showHide`,
|
||||
//! then `mixer.showHide.<A1|S1>` per strip) and Meter Input(s) Only (`mixer.menu.meterInputOnly`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use filmcraft_engine::mixer::strip_label;
|
||||
use filmcraft_project::mixer::{FADER_MAX_DB, FADER_MIN_DB, LANE_MUTE, LANE_PAN, LANE_VOLUME, MASTER_STRIP};
|
||||
use filmcraft_project::mixer::{
|
||||
FADER_MAX_DB, FADER_MIN_DB, LANE_MUTE, LANE_PAN, LANE_PAN51_CENTER, LANE_PAN51_LFE, LANE_PAN51_X, LANE_PAN51_Y, LANE_VOLUME, MASTER_STRIP,
|
||||
};
|
||||
use filmcraft_project::{AutomationMode, InputMap, Sequence, Track, TrackId};
|
||||
use filmcraft_time::{TimeDisplay, format_time};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -71,32 +79,44 @@ fn db_text(db: f64) -> String {
|
|||
|
||||
// ------------------------------------------------------------------------------------- meters
|
||||
|
||||
/// Per-strip meter state, `strip id → [level L, level R, peak L, peak R]` (dBFS), fed by the playing
|
||||
/// mix ([`filmcraft_render::mixer::LiveMix`]) with fast attack and a 20 dB/s fall.
|
||||
pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap<u64, [f32; 4]> {
|
||||
/// Meter key of the recording input (Meter Input(s) Only).
|
||||
pub const INPUT_METER: u64 = u64::MAX - 1;
|
||||
|
||||
/// Per-strip meter state, `strip id → [level, peak]` per channel (dBFS; 2 channels, or 6 for 5.1
|
||||
/// strips), fed by the playing mix ([`filmcraft_render::mixer::LiveMix`]) with fast attack and a
|
||||
/// 20 dB/s fall. [`INPUT_METER`] holds the voice-over input.
|
||||
pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap<u64, Vec<[f32; 2]>> {
|
||||
let id = egui::Id::new("mixer-meters");
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
let (last, mut st): (u64, HashMap<u64, [f32; 4]>) = ui.data(|d| d.get_temp(id)).unwrap_or_default();
|
||||
let (last, mut st): (u64, HashMap<u64, Vec<[f32; 2]>>) = ui.data(|d| d.get_temp(id)).unwrap_or_default();
|
||||
if last == frame && frame != 0 {
|
||||
return st;
|
||||
}
|
||||
// without an audio device, the loudness feed is what mixes the programme (and posts meters)
|
||||
super::meters::feed_loudness(app);
|
||||
let fresh = app.session.previews.live.take_meters();
|
||||
let mut fresh: Vec<(u64, Vec<f32>)> = app.session.previews.live.take_meters().into_iter().map(|(k, v)| (k.0, v)).collect();
|
||||
let input = app.session.previews.live.take_input_meter();
|
||||
if !input.is_empty() {
|
||||
fresh.push((INPUT_METER, input));
|
||||
}
|
||||
let dt = ui.input(|i| i.stable_dt).min(0.1);
|
||||
for v in st.values_mut() {
|
||||
v[0] = (v[0] - 20.0 * dt).max(-90.0);
|
||||
v[1] = (v[1] - 20.0 * dt).max(-90.0);
|
||||
v[2] = (v[2] - 6.0 * dt).max(-90.0);
|
||||
v[3] = (v[3] - 6.0 * dt).max(-90.0);
|
||||
for m in v.iter_mut() {
|
||||
m[0] = (m[0] - 20.0 * dt).max(-90.0);
|
||||
m[1] = (m[1] - 6.0 * dt).max(-90.0);
|
||||
}
|
||||
}
|
||||
if app.playback.playing {
|
||||
let live = app.playback.playing || app.session.voiceover.recording();
|
||||
if live {
|
||||
for (k, p) in fresh {
|
||||
let e = st.entry(k.0).or_insert([-90.0; 4]);
|
||||
for c in 0..2 {
|
||||
let l = 20.0 * p[c].max(1e-6).log10();
|
||||
e[c] = e[c].max(l);
|
||||
e[c + 2] = e[c + 2].max(l);
|
||||
let e = st.entry(k).or_default();
|
||||
if e.len() != p.len() {
|
||||
*e = vec![[-90.0; 2]; p.len()];
|
||||
}
|
||||
for (m, x) in e.iter_mut().zip(&p) {
|
||||
let l = 20.0 * x.max(1e-6).log10();
|
||||
m[0] = m[0].max(l);
|
||||
m[1] = m[1].max(l);
|
||||
}
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
|
|
@ -105,19 +125,28 @@ pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap<u64, [f32;
|
|||
st
|
||||
}
|
||||
|
||||
fn draw_meters(ui: &egui::Ui, r: Rect, m: [f32; 4], t: &Tokens) {
|
||||
/// One bar per channel (level, peak hold) with a clip light on top; 5.1 meters carry channel names.
|
||||
fn draw_meters(ui: &egui::Ui, r: Rect, m: &[[f32; 2]], channels: usize, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, Color32::BLACK);
|
||||
let w = (r.width() - 2.0) / 2.0;
|
||||
for c in 0..2 {
|
||||
let br = Rect::from_min_size(pos2(r.min.x + c as f32 * (w + 2.0), r.min.y + 6.0), vec2(w, r.height() - 6.0));
|
||||
crate::widgets::meter_bar(p, br, m[c], m[c + 2], t);
|
||||
let n = channels.max(1);
|
||||
let gap = if n > 2 { 1.0 } else { 2.0 };
|
||||
let w = (r.width() - gap * (n as f32 - 1.0)) / n as f32;
|
||||
for c in 0..n {
|
||||
let v = m.get(c).copied().unwrap_or([-90.0; 2]);
|
||||
let br = Rect::from_min_size(pos2(r.min.x + c as f32 * (w + gap), r.min.y + 6.0), vec2(w, r.height() - 6.0));
|
||||
crate::widgets::meter_bar(p, br, v[0], v[1], t);
|
||||
// clip light
|
||||
let lr = Rect::from_min_size(pos2(br.min.x, r.min.y), vec2(w, 4.0));
|
||||
p.rect_filled(lr, 0.0, if m[c + 2] >= -0.1 { RED } else { Color32::from_gray(0x30) });
|
||||
p.rect_filled(lr, 0.0, if v[1] >= -0.1 { RED } else { Color32::from_gray(0x30) });
|
||||
}
|
||||
}
|
||||
|
||||
/// Meter levels for the automation label, one per channel (silent channels read -90.0).
|
||||
fn meter_text(m: &[[f32; 2]], channels: usize) -> String {
|
||||
(0..channels).map(|c| format!("{:.1}", m.get(c).map_or(-90.0, |v| v[0]))).collect::<Vec<_>>().join(" / ")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------- helpers
|
||||
|
||||
struct Ctx<'a> {
|
||||
|
|
@ -189,7 +218,61 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if aresp.clicked() {
|
||||
app.ui.mixer_fx_open = !app.ui.mixer_fx_open;
|
||||
}
|
||||
let strips = seq.strip_ids();
|
||||
// panel menu: Show/Hide Tracks, Meter Input(s) Only
|
||||
let menu_r = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 26.0), vec2(14.0, 18.0));
|
||||
let mresp = ui.interact(menu_r, egui::Id::new("mixer-menu"), Sense::click()).on_hover_text("Audio Track Mixer menu");
|
||||
icons::paint(ui.painter(), menu_r.shrink(2.0), Icon::Hamburger, if mresp.hovered() { t.tab_text_active } else { t.icon });
|
||||
app.auto.add("mixer.menu", menu_r, "Audio Track Mixer menu");
|
||||
let show_hide_id = egui::Id::new("mixer-show-hide-open");
|
||||
let mut show_hide: bool = ui.data(|d| d.get_temp(show_hide_id)).unwrap_or(false);
|
||||
egui::Popup::menu(&mresp).show(|ui| {
|
||||
let r = ui.button("Show/Hide Tracks…");
|
||||
app.auto.add("mixer.menu.showHide", r.rect, "Show/Hide Tracks…");
|
||||
if r.clicked() {
|
||||
show_hide = true;
|
||||
}
|
||||
let on = app.ui.mixer_meter_input_only;
|
||||
let r = ui.selectable_label(on, "Meter Input(s) Only");
|
||||
app.auto.add("mixer.menu.meterInputOnly", r.rect, "Meter Input(s) Only");
|
||||
if r.clicked() {
|
||||
app.ui.mixer_meter_input_only = !on;
|
||||
}
|
||||
});
|
||||
if show_hide {
|
||||
let mut open = true;
|
||||
let mut close = false;
|
||||
egui::Window::new("Show/Hide Tracks").id(egui::Id::new("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show(ui.ctx(), |ui| {
|
||||
for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) {
|
||||
let label = strip_label(&seq, id);
|
||||
let name = seq.mix_track(id).map(|t| t.name.clone()).unwrap_or_default();
|
||||
let mut shown = !app.ui.mixer_hidden.contains(&id.0);
|
||||
let r = ui.checkbox(&mut shown, format!("{label} {name}"));
|
||||
app.auto.add(&format!("mixer.showHide.{label}"), r.rect, &format!("Show {label}"));
|
||||
if r.changed() {
|
||||
if shown {
|
||||
app.ui.mixer_hidden.retain(|h| *h != id.0);
|
||||
} else if !app.ui.mixer_hidden.contains(&id.0) {
|
||||
app.ui.mixer_hidden.push(id.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.button("Show All");
|
||||
app.auto.add("mixer.showHide.all", r.rect, "Show All");
|
||||
if r.clicked() {
|
||||
app.ui.mixer_hidden.clear();
|
||||
}
|
||||
let r = ui.button("OK");
|
||||
app.auto.add("mixer.showHide.ok", r.rect, "OK");
|
||||
if r.clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
show_hide = open && !close;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(show_hide_id, show_hide));
|
||||
let strips: Vec<TrackId> = seq.strip_ids().into_iter().filter(|id| !app.ui.mixer_hidden.contains(&id.0)).collect();
|
||||
let x0 = body.min.x + 18.0;
|
||||
let total = strips.len() as f32 * (STRIP_W + 2.0);
|
||||
let avail = body.max.x - x0 - 2.0;
|
||||
|
|
@ -248,8 +331,14 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
continue;
|
||||
}
|
||||
let sr = Rect::from_min_size(pos2(sx, clip.min.y + 2.0 - voff), vec2(STRIP_W, (clip.height() - 4.0).max(need)));
|
||||
let m = meters.get(&id.0).copied().unwrap_or([-90.0; 4]);
|
||||
strip(app, &mut cui, &mut cx, *id, sr, m);
|
||||
let is_track = seq.audio_tracks.iter().any(|t| t.id == *id && t.mixer.record_arm);
|
||||
let m = if app.ui.mixer_meter_input_only {
|
||||
// Meter Input(s) Only: armed tracks show the recording input; other meters are idle
|
||||
if is_track { meters.get(&INPUT_METER).cloned().unwrap_or_default() } else { Vec::new() }
|
||||
} else {
|
||||
meters.get(&id.0).cloned().unwrap_or_default()
|
||||
};
|
||||
strip(app, &mut cui, &mut cx, *id, sr, &m);
|
||||
}
|
||||
transport(app, ui, footer, &mut cx);
|
||||
let acts = std::mem::take(&mut cx.acts);
|
||||
|
|
@ -269,7 +358,7 @@ fn run_actions(app: &mut FilmcraftApp, ctx: &egui::Context, acts: Vec<(String, V
|
|||
}
|
||||
}
|
||||
|
||||
fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, sr: Rect, meter: [f32; 4]) {
|
||||
fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, sr: Rect, meter: &[[f32; 2]]) {
|
||||
let t = cx.t;
|
||||
let seq = cx.seq;
|
||||
let Some(tr) = seq.strip(id).map(|c| c.into_owned()) else { return };
|
||||
|
|
@ -479,39 +568,44 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
|||
}
|
||||
});
|
||||
y += 26.0;
|
||||
// ---- pan knob
|
||||
let pan = shown(app, &tr, LANE_PAN, cx.now);
|
||||
let kc = pos2(sr.center().x, y + 20.0);
|
||||
let kr = Rect::from_center_size(kc, vec2(38.0, 38.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text("Pan / balance (drag; double-click: centre)");
|
||||
let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) };
|
||||
ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring));
|
||||
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
|
||||
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 14.0], Stroke::new(2.0, ring));
|
||||
small_text(ui, pos2(kc.x - 22.0, kc.y + 17.0), Align2::CENTER_CENTER, "L", 11.0, t.hot_text);
|
||||
small_text(ui, pos2(kc.x + 22.0, kc.y + 17.0), Align2::CENTER_CENTER, "R", 11.0, t.hot_text);
|
||||
app.auto.add(&format!("{ap}.pan"), kr, "Pan");
|
||||
if kresp.dragged() {
|
||||
let d = kresp.drag_delta();
|
||||
let np = (pan + (d.x - d.y) as f64).clamp(-100.0, 100.0);
|
||||
if np != pan {
|
||||
cx.cmd("mixer.touch", json!({"strip": sid, "lane": LANE_PAN, "value": np, "time": cx.now.0}));
|
||||
if seq.pans_51(id) {
|
||||
pan51_panner(app, ui, cx, &tr, &ap, &sid, Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 62.0)));
|
||||
y += 62.0;
|
||||
} else {
|
||||
// ---- pan knob
|
||||
let pan = shown(app, &tr, LANE_PAN, cx.now);
|
||||
let kc = pos2(sr.center().x, y + 20.0);
|
||||
let kr = Rect::from_center_size(kc, vec2(38.0, 38.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text("Pan / balance (drag; double-click: centre)");
|
||||
let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) };
|
||||
ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring));
|
||||
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
|
||||
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 14.0], Stroke::new(2.0, ring));
|
||||
small_text(ui, pos2(kc.x - 22.0, kc.y + 17.0), Align2::CENTER_CENTER, "L", 11.0, t.hot_text);
|
||||
small_text(ui, pos2(kc.x + 22.0, kc.y + 17.0), Align2::CENTER_CENTER, "R", 11.0, t.hot_text);
|
||||
app.auto.add(&format!("{ap}.pan"), kr, "Pan");
|
||||
if kresp.dragged() {
|
||||
let d = kresp.drag_delta();
|
||||
let np = (pan + (d.x - d.y) as f64).clamp(-100.0, 100.0);
|
||||
if np != pan {
|
||||
cx.cmd("mixer.touch", json!({"strip": sid, "lane": LANE_PAN, "value": np, "time": cx.now.0}));
|
||||
}
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
cx.cmd("mixer.release", json!({"strip": sid, "lane": LANE_PAN, "time": cx.now.0}));
|
||||
}
|
||||
if kresp.double_clicked() {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": 0.0}));
|
||||
}
|
||||
let vr = Rect::from_center_size(pos2(kc.x, y + 48.0), vec2(56.0, 16.0));
|
||||
let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr).layout(egui::Layout::top_down(egui::Align::Center)));
|
||||
let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t);
|
||||
app.auto.add(&format!("{ap}.panValue"), vresp.rect, &format!("{pan:.1}"));
|
||||
if let Some(v) = nv {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": v}));
|
||||
}
|
||||
y += 62.0;
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
cx.cmd("mixer.release", json!({"strip": sid, "lane": LANE_PAN, "time": cx.now.0}));
|
||||
}
|
||||
if kresp.double_clicked() {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": 0.0}));
|
||||
}
|
||||
let vr = Rect::from_center_size(pos2(kc.x, y + 48.0), vec2(56.0, 16.0));
|
||||
let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr).layout(egui::Layout::top_down(egui::Align::Center)));
|
||||
let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t);
|
||||
app.auto.add(&format!("{ap}.panValue"), vresp.rect, &format!("{pan:.1}"));
|
||||
if let Some(v) = nv {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": v}));
|
||||
}
|
||||
y += 62.0;
|
||||
} else {
|
||||
y += 112.0;
|
||||
}
|
||||
|
|
@ -621,9 +715,11 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
|||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_VOLUME, "value": 0.0}));
|
||||
}
|
||||
// meters with their own scale (0 … −54 dB)
|
||||
let nch = filmcraft_render::mixer::width_of(tr.channels);
|
||||
let mrect = Rect::from_min_max(pos2(x + 46.0, area.min.y), pos2(x + 70.0, area.max.y));
|
||||
draw_meters(ui, mrect, meter, &t);
|
||||
app.auto.add(&format!("{ap}.meter"), mrect, &format!("Meter {:.1} / {:.1} dB", meter[0], meter[1]));
|
||||
let nshow = if meter.is_empty() { nch } else { meter.len() };
|
||||
draw_meters(ui, mrect, meter, nshow, &t);
|
||||
app.auto.add(&format!("{ap}.meter"), mrect, &format!("Meter {} dB", meter_text(meter, nshow)));
|
||||
let bar_top = mrect.min.y + 6.0;
|
||||
let bar_h = mrect.height() - 6.0;
|
||||
let mut d = 0;
|
||||
|
|
@ -657,6 +753,70 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
|||
app.auto.add(&format!("{ap}.name"), nr, &tr.name);
|
||||
}
|
||||
|
||||
/// The 5.1 panner: a puck in the listening square (x left … right, y rear … front; speakers drawn
|
||||
/// at L, C, R, Ls, Rs), and the Center % and LFE values. Drags are `mixer.touch` / `mixer.release`
|
||||
/// on the `pan51.x` / `pan51.y` lanes, so they are heard live and recorded like the pan knob.
|
||||
fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Track, ap: &str, sid: &Value, r: Rect) {
|
||||
let t = cx.t;
|
||||
let px = shown(app, tr, LANE_PAN51_X, cx.now);
|
||||
let py = shown(app, tr, LANE_PAN51_Y, cx.now);
|
||||
let sq = Rect::from_min_size(pos2(r.min.x, r.min.y), vec2(46.0, 46.0));
|
||||
let resp = ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text("5.1 panner (drag the puck; double-click: front centre)");
|
||||
let p = ui.painter();
|
||||
p.rect_filled(sq, 3.0, Color32::from_gray(0x16));
|
||||
p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { Color32::from_gray(0x60) }), StrokeKind::Inside);
|
||||
let inner = sq.shrink(5.0);
|
||||
let to_pos =
|
||||
|x: f64, y: f64| pos2(inner.center().x + (x / 100.0) as f32 * inner.width() / 2.0, inner.center().y - (y / 100.0) as f32 * inner.height() / 2.0);
|
||||
// speakers
|
||||
for (x, y) in [(-100.0, 100.0), (0.0, 100.0), (100.0, 100.0), (-100.0, -100.0), (100.0, -100.0)] {
|
||||
p.rect_filled(Rect::from_center_size(to_pos(x, y), vec2(4.0, 4.0)), 1.0, Color32::from_gray(0x90));
|
||||
}
|
||||
let puck = to_pos(px, py);
|
||||
p.circle_filled(puck, 4.0, if resp.dragged() { t.accent } else { t.hot_text });
|
||||
app.auto.add(&format!("{ap}.pan51"), sq, &format!("5.1 panner x {px:.0} y {py:.0}"));
|
||||
if resp.dragged() {
|
||||
let d = resp.drag_delta();
|
||||
let nx = (px + (d.x / (inner.width() / 2.0) * 100.0) as f64).clamp(-100.0, 100.0);
|
||||
let ny = (py - (d.y / (inner.height() / 2.0) * 100.0) as f64).clamp(-100.0, 100.0);
|
||||
if nx != px || resp.drag_started() {
|
||||
cx.cmd("mixer.touch", json!({"strip": sid, "lane": LANE_PAN51_X, "value": nx, "time": cx.now.0}));
|
||||
}
|
||||
if ny != py || resp.drag_started() {
|
||||
cx.cmd("mixer.touch", json!({"strip": sid, "lane": LANE_PAN51_Y, "value": ny, "time": cx.now.0}));
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
cx.cmd("mixer.release", json!({"strip": sid, "lane": LANE_PAN51_X, "time": cx.now.0}));
|
||||
cx.cmd("mixer.release", json!({"strip": sid, "lane": LANE_PAN51_Y, "time": cx.now.0}));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_X, "value": 0.0}));
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_Y, "value": 100.0}));
|
||||
}
|
||||
// Center % and LFE (hot numbers)
|
||||
let center = shown(app, tr, LANE_PAN51_CENTER, cx.now);
|
||||
let lfe = shown(app, tr, LANE_PAN51_LFE, cx.now);
|
||||
let col_x = sq.max.x + 4.0;
|
||||
small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, "Center", 9.0, t.text_dim);
|
||||
let cr = Rect::from_min_size(pos2(col_x, r.min.y + 12.0), vec2(r.max.x - col_x, 14.0));
|
||||
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr));
|
||||
let (cresp, nc) = crate::widgets::hot_number(&mut cui, egui::Id::new((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t);
|
||||
app.auto.add(&format!("{ap}.pan51.center"), cresp.rect, &format!("Center {center:.0} %"));
|
||||
if let Some(v) = nc {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_CENTER, "value": v}));
|
||||
}
|
||||
small_text(ui, pos2(col_x, r.min.y + 32.0), Align2::LEFT_CENTER, "LFE", 9.0, t.text_dim);
|
||||
let lr = Rect::from_min_size(pos2(col_x, r.min.y + 38.0), vec2(r.max.x - col_x, 14.0));
|
||||
let mut lui = ui.new_child(egui::UiBuilder::new().max_rect(lr));
|
||||
let (lresp, nl) =
|
||||
crate::widgets::hot_number(&mut lui, egui::Id::new((ap, "pan51.lfe")), lfe.max(FADER_MIN_DB), 0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t);
|
||||
app.auto.add(&format!("{ap}.pan51.lfe"), lresp.rect, &format!("LFE {}", db_text(lfe)));
|
||||
if let Some(v) = nl {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_LFE, "value": v}));
|
||||
}
|
||||
}
|
||||
|
||||
fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx) {
|
||||
let t = cx.t;
|
||||
let seq = cx.seq;
|
||||
|
|
@ -672,7 +832,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx)
|
|||
(if playing { Icon::Pause } else { Icon::Play }, "playback.toggle", "Play-Stop Toggle (Space)"),
|
||||
(Icon::Play, "playback.inToOut", "Play In to Out"),
|
||||
(Icon::Loop, "playback.loop", "Loop"),
|
||||
(Icon::Mic, "record", "Record (voice-over recording is not available yet)"),
|
||||
(Icon::Mic, "voiceover.recordToggle", "Record (voice-over on the record-enabled track)"),
|
||||
];
|
||||
let bw = 30.0;
|
||||
let mut x = row.center().x - bw * buttons.len() as f32 / 2.0;
|
||||
|
|
@ -691,8 +851,9 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx)
|
|||
} else {
|
||||
t.icon
|
||||
};
|
||||
if cmd == "record" {
|
||||
ui.painter().circle_filled(r.center(), 6.0, if cx.recording { RED } else { RED.gamma_multiply(0.55) });
|
||||
if cmd == "voiceover.recordToggle" {
|
||||
let on = app.session.voiceover.recording();
|
||||
ui.painter().circle_filled(r.center(), 6.0, if on { RED } else { RED.gamma_multiply(0.55) });
|
||||
} else {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, col);
|
||||
if cmd == "playback.inToOut" {
|
||||
|
|
@ -703,7 +864,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx)
|
|||
}
|
||||
}
|
||||
}
|
||||
if resp.clicked() && cmd != "record" {
|
||||
if resp.clicked() {
|
||||
cx.cmd(cmd, json!({}));
|
||||
}
|
||||
x += bw;
|
||||
|
|
@ -741,29 +902,59 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let vol = clip.and_then(|c| c.effect("volume")).map(|e| e.f64_at("level", mt.unwrap_or_default())).unwrap_or(0.0);
|
||||
let pan = clip.and_then(|c| c.effect("panner")).map(|e| e.f64_at("balance", mt.unwrap_or_default())).unwrap_or(0.0);
|
||||
let kf = *write_kf.get(&tr.id.0).unwrap_or(&false);
|
||||
// during an automation pass, moves are live gestures (recorded in Latch / Touch / Write)
|
||||
let gestures = app.session.mixrec.active();
|
||||
let lane_of = |effect: &str| if effect == "panner" { "pan" } else { "volume" };
|
||||
let set = |acts: &mut Vec<(String, Value)>, effect: &str, param: &str, v: f64, begin: bool| {
|
||||
if let Some(c) = clip {
|
||||
if gestures {
|
||||
acts.push(("clipMixer.touch".into(), json!({"track": tr.id.0, "lane": lane_of(effect), "value": v, "time": now.0})));
|
||||
} else if let Some(c) = clip {
|
||||
acts.push(("clipMixer.set".into(), json!({"clip": c.id.0, "effect": effect, "param": param, "value": v, "keyframe": kf, "begin": begin})));
|
||||
}
|
||||
};
|
||||
let release = |acts: &mut Vec<(String, Value)>, effect: &str| {
|
||||
if gestures {
|
||||
acts.push(("clipMixer.release".into(), json!({"track": tr.id.0, "lane": lane_of(effect), "time": now.0})));
|
||||
}
|
||||
};
|
||||
let live = &app.session.previews.live;
|
||||
let vol = live.get(tr.id, filmcraft_render::audio::CLIP_LANE_VOLUME).map(|o| o.value).unwrap_or(vol);
|
||||
let pan = live.get(tr.id, filmcraft_render::audio::CLIP_LANE_PAN).map(|o| o.value).unwrap_or(pan);
|
||||
// pan knob
|
||||
let kc = pos2(sr.center().x, sr.min.y + 26.0);
|
||||
let kr = Rect::from_center_size(kc, vec2(34.0, 34.0));
|
||||
let kc = pos2(sr.center().x, sr.min.y + 36.0);
|
||||
let kr = Rect::from_center_size(kc, vec2(30.0, 30.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag());
|
||||
ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(Color32::from_gray(0xb0))));
|
||||
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
|
||||
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(Color32::from_gray(0xb0))));
|
||||
small_text(ui, pos2(kc.x - 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "L", 11.0, dimc(t.hot_text));
|
||||
small_text(ui, pos2(kc.x + 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "R", 11.0, dimc(t.hot_text));
|
||||
small_text(ui, pos2(kc.x, kc.y + 32.0), Align2::CENTER_CENTER, &format!("{pan:.1}"), 11.5, dimc(t.hot_text));
|
||||
small_text(ui, pos2(kc.x, kc.y + 26.0), Align2::CENTER_CENTER, &format!("{pan:.1}"), 11.5, dimc(t.hot_text));
|
||||
app.auto.add(&format!("{ap}.pan"), kr, "Clip pan");
|
||||
if kresp.dragged() && active {
|
||||
let d = kresp.drag_delta();
|
||||
set(&mut acts, "panner", "balance", (pan + (d.x - d.y) as f64).clamp(-100.0, 100.0), kresp.drag_started());
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
release(&mut acts, "panner");
|
||||
}
|
||||
if kresp.double_clicked() {
|
||||
set(&mut acts, "panner", "balance", 0.0, true);
|
||||
}
|
||||
// automation mode (Clip Mixer)
|
||||
let mode = filmcraft_engine::mixer::clip_mode(&app.session, tr.id);
|
||||
let mdr = Rect::from_min_size(pos2(sr.min.x + 4.0, sr.min.y + 2.0), vec2(sr.width() - 8.0, 16.0));
|
||||
let mdresp = crate::widgets::dropdown_text(ui, mdr, mode.label(), &t, egui::Id::new((&ap, "mode")));
|
||||
app.auto.add(&format!("{ap}.mode"), mdr, &format!("Clip automation mode: {}", mode.label()));
|
||||
egui::Popup::menu(&mdresp).show(|ui| {
|
||||
for m in AutomationMode::ALL {
|
||||
let r = ui.selectable_label(m == mode, m.label());
|
||||
app.auto.add(&format!("{ap}.mode.{}", m.label()), r.rect, m.label());
|
||||
if r.clicked() {
|
||||
acts.push(("clipMixer.setMode".into(), json!({"track": tr.id.0, "mode": m.label()})));
|
||||
}
|
||||
}
|
||||
});
|
||||
// M S ◇
|
||||
let y = sr.min.y + 72.0;
|
||||
let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - 33.0 + k * 24.0, y), vec2(18.0, 16.0));
|
||||
|
|
@ -803,11 +994,15 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if fresp.dragged() && active && fresp.drag_delta().y != 0.0 {
|
||||
set(&mut acts, "volume", "level", pos_to_db(db_to_pos(vol) - fresp.drag_delta().y / track.height()), fresp.drag_started());
|
||||
}
|
||||
if fresp.drag_stopped() {
|
||||
release(&mut acts, "volume");
|
||||
}
|
||||
if fresp.double_clicked() {
|
||||
set(&mut acts, "volume", "level", 0.0, true);
|
||||
}
|
||||
let mrect = Rect::from_min_max(pos2(sr.min.x + 46.0, area.min.y), pos2(sr.max.x - 6.0, area.max.y));
|
||||
draw_meters(ui, mrect, meters.get(&tr.id.0).copied().unwrap_or([-90.0; 4]), &t);
|
||||
let m = meters.get(&tr.id.0).cloned().unwrap_or_default();
|
||||
draw_meters(ui, mrect, &m, if m.is_empty() { filmcraft_render::mixer::width_of(tr.channels) } else { m.len() }, &t);
|
||||
}
|
||||
small_text(ui, pos2(sr.min.x + 8.0, sr.max.y - 34.0), Align2::LEFT_CENTER, &db_text(vol), 11.5, dimc(t.hot_text));
|
||||
app.auto.add(&format!("{ap}.value"), Rect::from_min_size(pos2(sr.min.x + 4.0, sr.max.y - 42.0), vec2(50.0, 16.0)), &db_text(vol));
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub mod panel_state;
|
|||
pub mod presets;
|
||||
pub mod project;
|
||||
pub mod reference;
|
||||
pub mod remix;
|
||||
pub mod scopes;
|
||||
pub mod settings;
|
||||
pub mod shortcuts_dialog;
|
||||
|
|
@ -38,6 +39,7 @@ pub mod timeline_automation;
|
|||
pub mod timeline_captions;
|
||||
pub mod tools;
|
||||
pub mod trim_monitor;
|
||||
pub mod voiceover;
|
||||
|
||||
use egui::{Align2, Color32, Rect};
|
||||
use filmcraft_project::ItemId;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
//! Clip ▸ Remix ▸ Remix Properties…: Target Duration, Segments and Variations of the selected
|
||||
//! remixed clip. OK runs `clip.remix.properties` (one undo step); the Remix tool in the timeline
|
||||
//! sets the target by dragging instead.
|
||||
//!
|
||||
//! Automation ids: `remixProperties.duration` (seconds), `remixProperties.segments`,
|
||||
//! `remixProperties.variations` (sliders 0 … 100), `remixProperties.info` (tempo and the duration
|
||||
//! the remix will have), `remixProperties.ok`, `remixProperties.cancel`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Draft {
|
||||
clip: u64,
|
||||
seconds: f64,
|
||||
segments: f64,
|
||||
variations: f64,
|
||||
info: String,
|
||||
}
|
||||
|
||||
fn draft_id() -> egui::Id {
|
||||
egui::Id::new("remix-properties-draft")
|
||||
}
|
||||
|
||||
/// Open the dialog for the selected remixed clip (the menu entry without parameters).
|
||||
pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context) -> Result<Value, String> {
|
||||
let v = app.session.execute("clip.remix.properties", json!({})).map_err(|e| e.to_string())?;
|
||||
let d = Draft {
|
||||
clip: v["clip"].as_u64().unwrap_or(0),
|
||||
seconds: v["targetSeconds"].as_f64().unwrap_or_else(|| v["seconds"].as_f64().unwrap_or(0.0)),
|
||||
segments: v["segments"].as_f64().unwrap_or(50.0),
|
||||
variations: v["variations"].as_f64().unwrap_or(50.0),
|
||||
info: format!("Current duration {:.2} s", v["seconds"].as_f64().unwrap_or(0.0)),
|
||||
};
|
||||
ctx.data_mut(|m| m.insert_temp(draft_id(), Some(d)));
|
||||
Ok(json!({"dialog": "remixProperties"}))
|
||||
}
|
||||
|
||||
/// Whether the dialog is open.
|
||||
pub fn is_open(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())).flatten().is_some()
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(Some(mut d)) = ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())) else { return };
|
||||
let mut close = false;
|
||||
let mut apply = false;
|
||||
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
|
||||
egui::Window::new("Remix Properties").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
|
||||
ui.set_min_width(320.0);
|
||||
egui::Grid::new("remix-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| {
|
||||
ui.label("Target Duration:");
|
||||
let r = ui.add(egui::DragValue::new(&mut d.seconds).speed(0.1).range(1.0..=36_000.0).suffix(" s").max_decimals(2));
|
||||
elems.push(("remixProperties.duration".into(), r.rect, format!("{:.2} s", d.seconds)));
|
||||
ui.end_row();
|
||||
ui.label("Segments:");
|
||||
let r = ui.add(egui::Slider::new(&mut d.segments, 0.0..=100.0).step_by(1.0).text("Fewer · More"));
|
||||
elems.push(("remixProperties.segments".into(), r.rect, format!("{:.0}", d.segments)));
|
||||
ui.end_row();
|
||||
ui.label("Variations:");
|
||||
let r = ui.add(egui::Slider::new(&mut d.variations, 0.0..=100.0).step_by(1.0).text("Fewer · More"));
|
||||
elems.push(("remixProperties.variations".into(), r.rect, format!("{:.0}", d.variations)));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let i = ui.weak(&d.info);
|
||||
elems.push(("remixProperties.info".into(), i.rect, d.info.clone()));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let c = ui.button("Cancel");
|
||||
elems.push(("remixProperties.cancel".into(), c.rect, "Cancel".into()));
|
||||
if c.clicked() {
|
||||
close = true;
|
||||
}
|
||||
let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent));
|
||||
elems.push(("remixProperties.ok".into(), o.rect, "OK".into()));
|
||||
if o.clicked() {
|
||||
apply = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
if apply {
|
||||
let p = json!({"clip": d.clip, "seconds": d.seconds, "segments": d.segments, "variations": d.variations});
|
||||
match app.session.execute("clip.remix.properties", p) {
|
||||
Ok(v) => app.ui.status = format!("Remixed to {:.2} s", v["seconds"].as_f64().unwrap_or(0.0)),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
ctx.data_mut(|m| m.insert_temp(draft_id(), if close { None } else { Some(d) }));
|
||||
}
|
||||
|
|
@ -59,16 +59,53 @@ impl TlState {
|
|||
#[derive(Clone, Debug)]
|
||||
pub enum Drag {
|
||||
Scrub,
|
||||
Move { clips: Vec<ClipId>, grab_tick: Tick, start_track: TrackId, offset: Tick, track_delta: i32 },
|
||||
Trim { clip: ClipId, edge: filmcraft_edit::Edge, mode: filmcraft_edit::TrimMode, delta: Tick },
|
||||
Roll { left: ClipId, right: ClipId, delta: Tick },
|
||||
Slip { clip: ClipId, delta: Tick },
|
||||
Slide { clip: ClipId, delta: Tick },
|
||||
Stretch { clip: ClipId, edge: filmcraft_edit::Edge, delta: Tick },
|
||||
Pan { last: Pos2 },
|
||||
Marquee { start: Pos2 },
|
||||
Move {
|
||||
clips: Vec<ClipId>,
|
||||
grab_tick: Tick,
|
||||
start_track: TrackId,
|
||||
offset: Tick,
|
||||
track_delta: i32,
|
||||
},
|
||||
Trim {
|
||||
clip: ClipId,
|
||||
edge: filmcraft_edit::Edge,
|
||||
mode: filmcraft_edit::TrimMode,
|
||||
delta: Tick,
|
||||
},
|
||||
Roll {
|
||||
left: ClipId,
|
||||
right: ClipId,
|
||||
delta: Tick,
|
||||
},
|
||||
Slip {
|
||||
clip: ClipId,
|
||||
delta: Tick,
|
||||
},
|
||||
Slide {
|
||||
clip: ClipId,
|
||||
delta: Tick,
|
||||
},
|
||||
Stretch {
|
||||
clip: ClipId,
|
||||
edge: filmcraft_edit::Edge,
|
||||
delta: Tick,
|
||||
},
|
||||
/// Remix tool: drag a music clip's Out edge; on release `clip.remix` re-plans it to the new duration.
|
||||
Remix {
|
||||
clip: ClipId,
|
||||
delta: Tick,
|
||||
},
|
||||
Pan {
|
||||
last: Pos2,
|
||||
},
|
||||
Marquee {
|
||||
start: Pos2,
|
||||
},
|
||||
Divider,
|
||||
ZoomBar { grab: f32, mode: u8 },
|
||||
ZoomBar {
|
||||
grab: f32,
|
||||
mode: u8,
|
||||
},
|
||||
}
|
||||
|
||||
/// Geometry of one visible track row.
|
||||
|
|
@ -732,6 +769,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
|||
let hw = app.ui.timeline.header_w;
|
||||
let tg = app.session.targeting();
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
let mut vo_action = None;
|
||||
for r in rows {
|
||||
let tr = seq.track(r.track).expect("track");
|
||||
let clip_rect = if r.kind == TrackKind::Video { vclip } else { aclip };
|
||||
|
|
@ -835,7 +873,9 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
|||
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "solo": !tr.solo})));
|
||||
}
|
||||
let vr = small(x + 42.0, upper_y);
|
||||
crate::widgets::icon_toggle(ui, vr.intersect(visible), Icon::Mic, true, t, egui::Id::new(("vo", r.track.0)), Some(t.text_dim));
|
||||
if let Some(a) = super::voiceover::header_button(app, ui, r.track, vr, visible, &label, t) {
|
||||
vo_action = Some(a);
|
||||
}
|
||||
let kr = small(x + 64.0, upper_y);
|
||||
super::timeline_automation::header_button(app, ui, seq, r, kr, visible, &label, t);
|
||||
}
|
||||
|
|
@ -873,6 +913,9 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
|||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(a) = vo_action {
|
||||
super::voiceover::run(app, &ui.ctx().clone(), a);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequence, layout: &Layout, t: &Tokens, seq_id: ItemId) {
|
||||
|
|
@ -1284,6 +1327,11 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut
|
|||
}
|
||||
}
|
||||
}
|
||||
Drag::Remix { clip, delta } => {
|
||||
if let Some((_, it)) = seq.find_item(*clip) {
|
||||
out.insert(*clip, (it.start, it.duration + *delta, None));
|
||||
}
|
||||
}
|
||||
Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta } => {
|
||||
let ids = filmcraft_engine::commands::with_links(&app.session, &[*clip]);
|
||||
for c in ids {
|
||||
|
|
@ -1369,6 +1417,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
let cur = match (tool, &h) {
|
||||
(Tool::Selection, Hit::Clip { edge: Some(_), .. }) => CursorIcon::ResizeColumn,
|
||||
(Tool::Ripple | Tool::Rolling | Tool::RateStretch, Hit::Clip { edge: Some(_), .. }) => CursorIcon::ResizeColumn,
|
||||
(Tool::Remix, Hit::Clip { edge: Some(filmcraft_edit::Edge::Out), .. }) => CursorIcon::ResizeColumn,
|
||||
(Tool::Razor, Hit::Clip { .. }) => CursorIcon::Crosshair,
|
||||
(Tool::Slip | Tool::Slide, Hit::Clip { .. }) => CursorIcon::ResizeHorizontal,
|
||||
(Tool::Hand, _) => CursorIcon::Grab,
|
||||
|
|
@ -1472,6 +1521,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
Some(Drag::Trim { clip, edge, mode, delta: Tick::ZERO })
|
||||
}
|
||||
}
|
||||
(Tool::Remix, Hit::Clip { clip, edge: Some(filmcraft_edit::Edge::Out), .. }) => Some(Drag::Remix { clip, delta: Tick::ZERO }),
|
||||
(Tool::Slip, Hit::Clip { clip, .. }) => Some(Drag::Slip { clip, delta: Tick::ZERO }),
|
||||
(Tool::Slide, Hit::Clip { clip, .. }) => Some(Drag::Slide { clip, delta: Tick::ZERO }),
|
||||
(_, Hit::Clip { clip, track, .. }) => {
|
||||
|
|
@ -1564,6 +1614,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
let delta = target - base;
|
||||
Some(Drag::Trim { clip, edge, mode, delta })
|
||||
}
|
||||
Drag::Remix { clip, .. } => {
|
||||
let (_, it) = seq.find_item(clip).expect("clip");
|
||||
let target = rate.snap_nearest(t_here).max(it.start + rate.frame_duration());
|
||||
Some(Drag::Remix { clip, delta: target - it.end() })
|
||||
}
|
||||
Drag::Stretch { clip, edge, .. } => {
|
||||
let (_, it) = seq.find_item(clip).expect("clip");
|
||||
let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() };
|
||||
|
|
@ -1623,6 +1678,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
|||
json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "mode": if mode == filmcraft_edit::TrimMode::Ripple {"ripple"} else {"regular"}, "delta": delta.0}),
|
||||
)),
|
||||
Drag::Stretch { clip, edge, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0}))),
|
||||
Drag::Remix { clip, delta } if delta != Tick::ZERO => {
|
||||
let d = seq.find_item(clip).map(|(_, it)| it.duration + delta).unwrap_or(Tick::ZERO);
|
||||
Some(app.session.execute("clip.remix", json!({"clip": clip.0, "duration": d.0})))
|
||||
}
|
||||
Drag::Roll { left, right, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.roll", json!({"left": left.0, "right": right.0, "delta": delta.0}))),
|
||||
Drag::Slip { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slip", json!({"clip": clip.0, "delta": delta.0}))),
|
||||
Drag::Slide { clip, delta } if delta != Tick::ZERO => Some(app.session.execute("timeline.slide", json!({"clip": clip.0, "delta": delta.0}))),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
//! Voice-over recording from the timeline: the audio track header's microphone button and the
|
||||
//! Voice-Over Record Settings dialog. Recording itself is the engine's `audio.voiceover.*`
|
||||
//! commands; this module ties them to playback (start from the capture start, restart the capture
|
||||
//! when the audio clock really starts, stop with playback or after the post-roll past the Out
|
||||
//! point) and draws the countdown.
|
||||
//!
|
||||
//! Automation ids: `timeline.track.<A1…>.voiceover` (click: record on that track / stop; right-
|
||||
//! click: menu), `timeline.track.<A1…>.voiceover.settings` (menu entry), and in the dialog
|
||||
//! `voiceover.source`, `voiceover.source.<i>`, `voiceover.input`, `voiceover.input.<i>`,
|
||||
//! `voiceover.name`, `voiceover.countdown`, `voiceover.preroll`, `voiceover.postroll`,
|
||||
//! `voiceover.ok`, `voiceover.cancel`; the countdown overlay is `voiceover.countdown.overlay`.
|
||||
//! UI commands: `voiceover.recordToggle {track?}` and `voiceover.settingsDialog`.
|
||||
|
||||
use egui::{Align2, Color32, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
use filmcraft_engine::voiceover::VoiceOverPrefs;
|
||||
use filmcraft_project::TrackId;
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
const RED: Color32 = Color32::from_rgb(0xd8, 0x50, 0x3f);
|
||||
|
||||
/// What the header button asked for.
|
||||
pub enum Action {
|
||||
Toggle(TrackId),
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Draft {
|
||||
prefs: VoiceOverPrefs,
|
||||
devices: Vec<String>,
|
||||
channels: u16,
|
||||
}
|
||||
|
||||
fn draft_id() -> egui::Id {
|
||||
egui::Id::new("voiceover-settings-draft")
|
||||
}
|
||||
|
||||
/// The microphone button of an audio track header. Returns the requested action.
|
||||
pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, track: TrackId, r: Rect, visible: Rect, label: &str, t: &Tokens) -> Option<Action> {
|
||||
let recording_here = app.session.voiceover.rec.as_ref().is_some_and(|x| x.track == track);
|
||||
let resp = ui.interact(r.intersect(visible), egui::Id::new(("vo", track.0)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
let col = if recording_here { RED } else { t.text_dim };
|
||||
icons::paint(&ui.painter().with_clip_rect(visible), r.shrink(1.0), Icon::Mic, col);
|
||||
let id = format!("timeline.track.{label}.voiceover");
|
||||
app.auto.add(&id, r, if recording_here { "Stop Voice-over Recording" } else { "Voice-over Record" });
|
||||
let mut act = None;
|
||||
if resp.clicked() {
|
||||
act = Some(Action::Toggle(track));
|
||||
}
|
||||
let resp = resp.on_hover_text("Voice-over record (right-click: Voice-Over Record Settings…)");
|
||||
resp.context_menu(|ui| {
|
||||
let b = ui.button("Voice-Over Record Settings…");
|
||||
app.auto.add(&format!("{id}.settings"), b.rect, "Voice-Over Record Settings…");
|
||||
if b.clicked() {
|
||||
act = Some(Action::Settings);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
act
|
||||
}
|
||||
|
||||
pub fn run(app: &mut FilmcraftApp, ctx: &egui::Context, a: Action) {
|
||||
let r = match a {
|
||||
Action::Toggle(t) => toggle(app, Some(t.0)),
|
||||
Action::Settings => {
|
||||
open_settings(app, ctx);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start recording on `track` (None = the engine's choice), or stop the recording in progress.
|
||||
pub fn toggle(app: &mut FilmcraftApp, track: Option<u64>) -> Result<Value, String> {
|
||||
if app.session.voiceover.recording() {
|
||||
if app.playback.playing {
|
||||
app.stop(); // runs `audio.voiceover.stop` (see `on_stop`)
|
||||
return Ok(json!({"recording": false}));
|
||||
}
|
||||
return stop(app);
|
||||
}
|
||||
if app.playback.playing {
|
||||
app.stop();
|
||||
}
|
||||
let p = match track {
|
||||
Some(t) => json!({"track": t}),
|
||||
None => json!({}),
|
||||
};
|
||||
let r = app.session.execute("audio.voiceover.start", p).map_err(|e| e.to_string())?;
|
||||
let from = Tick(r["captureStart"].as_i64().unwrap_or(0));
|
||||
app.session.set_playhead(from);
|
||||
app.play(1.0);
|
||||
app.ui.status = "Recording voice-over…".into();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn stop(app: &mut FilmcraftApp) -> Result<Value, String> {
|
||||
let t = app.session.playhead();
|
||||
let r = app.session.execute("audio.voiceover.stop", json!({"time": t.0})).map_err(|e| e.to_string())?;
|
||||
app.ui.status = match r["path"].as_str() {
|
||||
Some(p) => format!("Voice-over recorded: {p}"),
|
||||
None => "Voice-over recording stopped (nothing recorded)".into(),
|
||||
};
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Playback (and the audio clock) really started: capture from here.
|
||||
pub fn on_play(app: &mut FilmcraftApp) {
|
||||
if app.session.voiceover.recording() {
|
||||
let t = app.session.playhead();
|
||||
if let Err(e) = app.session.execute("audio.voiceover.sync", json!({"time": t.0})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback stopped: finish the take.
|
||||
pub fn on_stop(app: &mut FilmcraftApp) {
|
||||
if app.session.voiceover.recording()
|
||||
&& let Err(e) = stop(app)
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Countdown cue beeps of the recording in progress, for an output at `sr`: (start sample, tone).
|
||||
pub fn cues(s: &filmcraft_engine::Session, sr: u32) -> (Vec<i64>, Vec<f32>) {
|
||||
match &s.voiceover.rec {
|
||||
Some(rec) if s.prefs.voice_over.countdown_sound_cues => {
|
||||
let at = filmcraft_engine::voiceover::cue_times(rec.capture_start, rec.record_start).into_iter().map(|t| t.to_units_floor(sr as i64)).collect();
|
||||
(at, filmcraft_engine::voiceover::cue_tone(sr))
|
||||
}
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the cue beeps that fall in the output block starting at device sample `cursor`.
|
||||
pub fn mix_cues(buf: &mut [f32], ch: usize, cursor: i64, cues: &(Vec<i64>, Vec<f32>)) {
|
||||
let (at, tone) = cues;
|
||||
let ch = ch.max(1);
|
||||
let n = (buf.len() / ch) as i64;
|
||||
for &c in at {
|
||||
let (a, b) = (c.max(cursor), (c + tone.len() as i64).min(cursor + n));
|
||||
for i in a..b {
|
||||
let x = tone[(i - c) as usize];
|
||||
for s in &mut buf[(i - cursor) as usize * ch..(i - cursor + 1) as usize * ch] {
|
||||
*s += x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI commands `voiceover.recordToggle {track?}` and `voiceover.settingsDialog`.
|
||||
pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
match id {
|
||||
"voiceover.recordToggle" => {
|
||||
let track = match p.get("track") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let seq = app.session.active_sequence()?;
|
||||
match filmcraft_engine::mixer::strip_ref(seq, v) {
|
||||
Some(t) => Some(t.0),
|
||||
None => return Some(Err(format!("no audio track {v}"))),
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(toggle(app, track))
|
||||
}
|
||||
"voiceover.settingsDialog" => {
|
||||
open_settings(app, ctx);
|
||||
Some(Ok(Value::Null))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_settings(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let v = app.session.execute("audio.voiceover.settings", json!({})).unwrap_or_default();
|
||||
let d = Draft {
|
||||
prefs: app.session.prefs.voice_over.clone(),
|
||||
devices: v["devices"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(),
|
||||
channels: v["channels"].as_u64().unwrap_or(0) as u16,
|
||||
};
|
||||
ctx.data_mut(|m| m.insert_temp(draft_id(), Some(d)));
|
||||
}
|
||||
|
||||
/// Every frame: the settings dialog, the countdown overlay, and the punch-out + post-roll stop.
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
settings_dialog(app, ctx);
|
||||
let Some(rec) = app.session.voiceover.rec.clone() else { return };
|
||||
if !app.playback.playing {
|
||||
return;
|
||||
}
|
||||
let now = app.session.playhead();
|
||||
if let Some(out) = rec.punch_out {
|
||||
let post = Tick::from_seconds_f64(app.session.prefs.voice_over.postroll_seconds);
|
||||
if now >= out + post {
|
||||
app.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
let screen = ctx.content_rect();
|
||||
let text = if now < rec.record_start {
|
||||
let left = (rec.record_start - now).seconds().ceil().max(1.0) as i64;
|
||||
format!("{left}")
|
||||
} else if rec.punch_out.is_some_and(|o| now >= o) {
|
||||
"Post-roll".into()
|
||||
} else {
|
||||
"● Recording".into()
|
||||
};
|
||||
let r = Rect::from_center_size(pos2(screen.center().x, screen.min.y + 90.0), vec2(220.0, 64.0));
|
||||
egui::Area::new(egui::Id::new("voiceover-countdown")).order(egui::Order::Foreground).fixed_pos(r.min).interactable(false).show(ctx, |ui| {
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 8.0, Color32::from_black_alpha(180));
|
||||
p.rect_stroke(r, 8.0, Stroke::new(2.0, RED), egui::StrokeKind::Inside);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, &text, Tokens::semibold(if text.len() <= 2 { 36.0 } else { 20.0 }), Color32::WHITE);
|
||||
});
|
||||
app.auto.add("voiceover.countdown.overlay", r, &text);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(Some(mut d)) = ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())) else { return };
|
||||
let mut elems: Vec<(String, Rect, String)> = Vec::new();
|
||||
let mut close = false;
|
||||
let mut apply = 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| {
|
||||
ui.label("Name:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.prefs.name).desired_width(220.0));
|
||||
elems.push(("voiceover.name".into(), r.rect, d.prefs.name.clone()));
|
||||
ui.end_row();
|
||||
ui.label("Source:");
|
||||
let shown = if d.prefs.source.is_empty() { "Default Input".to_string() } else { 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())));
|
||||
for (i, (val, label)) in opts.into_iter().enumerate() {
|
||||
let r = ui.selectable_label(d.prefs.source == val, &label);
|
||||
elems.push((format!("voiceover.source.{i}"), r.rect, label));
|
||||
if r.clicked() {
|
||||
d.prefs.source = val;
|
||||
}
|
||||
}
|
||||
});
|
||||
elems.push(("voiceover.source".into(), cb.response.rect, shown));
|
||||
ui.end_row();
|
||||
ui.label("Input:");
|
||||
let n = d.channels.max(1) as u32;
|
||||
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) {
|
||||
let label = format!("Channel {}", c + 1);
|
||||
let r = ui.selectable_label(d.prefs.input_channel == c, &label);
|
||||
elems.push((format!("voiceover.input.{c}"), r.rect, label));
|
||||
if r.clicked() {
|
||||
d.prefs.input_channel = c;
|
||||
}
|
||||
}
|
||||
});
|
||||
elems.push(("voiceover.input".into(), cb.response.rect, cur));
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
let r = ui.checkbox(&mut d.prefs.countdown_sound_cues, "Countdown Sound Cues");
|
||||
elems.push(("voiceover.countdown".into(), r.rect, "Countdown Sound Cues".into()));
|
||||
ui.end_row();
|
||||
ui.label("Pre-roll:");
|
||||
let r = ui.add(egui::DragValue::new(&mut d.prefs.preroll_seconds).speed(0.1).range(0.0..=60.0).suffix(" seconds"));
|
||||
elems.push(("voiceover.preroll".into(), r.rect, format!("{}", d.prefs.preroll_seconds)));
|
||||
ui.end_row();
|
||||
ui.label("Post-roll:");
|
||||
let r = ui.add(egui::DragValue::new(&mut d.prefs.postroll_seconds).speed(0.1).range(0.0..=60.0).suffix(" seconds"));
|
||||
elems.push(("voiceover.postroll".into(), r.rect, format!("{}", d.prefs.postroll_seconds)));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(
|
||||
RichText::new("Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there.")
|
||||
.weak()
|
||||
.small(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let c = ui.button("Cancel");
|
||||
elems.push(("voiceover.cancel".into(), c.rect, "Cancel".into()));
|
||||
if c.clicked() {
|
||||
close = true;
|
||||
}
|
||||
let o = ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(accent));
|
||||
elems.push(("voiceover.ok".into(), o.rect, "OK".into()));
|
||||
if o.clicked() {
|
||||
apply = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
for (id, r, l) in elems {
|
||||
app.auto.add(&id, r, &l);
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
if apply {
|
||||
let v = &d.prefs;
|
||||
let p = json!({
|
||||
"source": v.source,
|
||||
"inputChannel": v.input_channel,
|
||||
"name": v.name,
|
||||
"countdownSoundCues": v.countdown_sound_cues,
|
||||
"prerollSeconds": v.preroll_seconds,
|
||||
"postrollSeconds": v.postroll_seconds,
|
||||
});
|
||||
match app.session.execute("audio.voiceover.settings", p) {
|
||||
Ok(_) => close = true,
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
ctx.data_mut(|m| m.insert_temp(draft_id(), if close { None } else { Some(d) }));
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ pub enum Tool {
|
|||
Ripple,
|
||||
Rolling,
|
||||
RateStretch,
|
||||
Remix,
|
||||
Razor,
|
||||
Slip,
|
||||
Slide,
|
||||
|
|
@ -27,13 +28,14 @@ pub enum Tool {
|
|||
}
|
||||
|
||||
impl Tool {
|
||||
pub const ALL: [Tool; 15] = [
|
||||
pub const ALL: [Tool; 16] = [
|
||||
Tool::Selection,
|
||||
Tool::TrackSelectForward,
|
||||
Tool::TrackSelectBackward,
|
||||
Tool::Ripple,
|
||||
Tool::Rolling,
|
||||
Tool::RateStretch,
|
||||
Tool::Remix,
|
||||
Tool::Razor,
|
||||
Tool::Slip,
|
||||
Tool::Slide,
|
||||
|
|
@ -52,6 +54,7 @@ impl Tool {
|
|||
Tool::Ripple => "Ripple Edit Tool",
|
||||
Tool::Rolling => "Rolling Edit Tool",
|
||||
Tool::RateStretch => "Rate Stretch Tool",
|
||||
Tool::Remix => "Remix Tool",
|
||||
Tool::Razor => "Razor Tool",
|
||||
Tool::Slip => "Slip Tool",
|
||||
Tool::Slide => "Slide Tool",
|
||||
|
|
@ -71,6 +74,7 @@ impl Tool {
|
|||
Tool::Ripple => "B",
|
||||
Tool::Rolling => "N",
|
||||
Tool::RateStretch => "R",
|
||||
Tool::Remix => "",
|
||||
Tool::Razor => "C",
|
||||
Tool::Slip => "Y",
|
||||
Tool::Slide => "U",
|
||||
|
|
@ -89,6 +93,7 @@ impl Tool {
|
|||
Tool::Ripple => Icon::Ripple,
|
||||
Tool::Rolling => Icon::Rolling,
|
||||
Tool::RateStretch => Icon::RateStretch,
|
||||
Tool::Remix => Icon::Remix,
|
||||
Tool::Razor => Icon::Razor,
|
||||
Tool::Slip => Icon::Slip,
|
||||
Tool::Slide => Icon::Slide,
|
||||
|
|
@ -112,7 +117,7 @@ impl Tool {
|
|||
vec![
|
||||
vec![Tool::Selection],
|
||||
vec![Tool::TrackSelectForward, Tool::TrackSelectBackward],
|
||||
vec![Tool::Ripple, Tool::Rolling, Tool::RateStretch],
|
||||
vec![Tool::Ripple, Tool::Rolling, Tool::RateStretch, Tool::Remix],
|
||||
vec![Tool::Razor],
|
||||
vec![Tool::Slip, Tool::Slide],
|
||||
vec![Tool::Pen, Tool::Rectangle, Tool::Ellipse],
|
||||
|
|
@ -388,6 +393,12 @@ pub struct UiState {
|
|||
/// Audio Track Mixer: effects and sends section expanded.
|
||||
#[serde(default)]
|
||||
pub mixer_fx_open: bool,
|
||||
/// Audio Track Mixer ▸ Show/Hide Tracks: strip ids (audio tracks, submixes) not shown.
|
||||
#[serde(default)]
|
||||
pub mixer_hidden: Vec<u64>,
|
||||
/// Audio Track Mixer ▸ Meter Input(s) Only: record-armed track meters show the recording input.
|
||||
#[serde(default)]
|
||||
pub mixer_meter_input_only: bool,
|
||||
/// Audio Gain dialog draft (mode, dB values).
|
||||
#[serde(default)]
|
||||
pub audio_gain: AudioGainDraft,
|
||||
|
|
@ -705,6 +716,8 @@ impl Default for UiState {
|
|||
transcript_search: String::new(),
|
||||
play_after_render: true,
|
||||
mixer_fx_open: false,
|
||||
mixer_hidden: Vec::new(),
|
||||
mixer_meter_input_only: false,
|
||||
settings: None,
|
||||
audio_gain: AudioGainDraft::default(),
|
||||
delete_tracks: DeleteTracksDraft::default(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
//! Headless UI tests of the M7.8 audio features: the 5.1 panner puck on Audio Track Mixer strips
|
||||
//! that feed a 5.1 Mix, six-channel meters, Show/Hide Tracks and Meter Input(s) Only, the mixer
|
||||
//! transport's record button, and the Audio Clip Mixer automation modes recording clip keyframes.
|
||||
//! The real `FilmcraftApp` runs under `egui_kittest`, driven by automation id and frame-by-frame
|
||||
//! pointer drags.
|
||||
//!
|
||||
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to render the window offscreen with wgpu and write
|
||||
//! `audio51-*.png` there.
|
||||
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
snapshots: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn demo() -> Self {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
||||
let (tx, rx) = channel();
|
||||
let app = FilmcraftApp::new(session).with_control(rx);
|
||||
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from);
|
||||
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
|
||||
if snapshots.is_some() {
|
||||
b = b.wgpu();
|
||||
}
|
||||
let harness = b.build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx, snapshots };
|
||||
d.frames(4);
|
||||
d
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
let ctx = self.harness.ctx.clone();
|
||||
let mut raw = std::mem::take(self.harness.input_mut());
|
||||
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||
*self.harness.input_mut() = raw;
|
||||
self.harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||
self.tx.send(req).unwrap();
|
||||
for _ in 0..600 {
|
||||
self.frames(1);
|
||||
if let Ok(v) = reply.try_recv() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
|
||||
fn ok(&mut self, method: &str, params: Value) -> Value {
|
||||
let v = self.call(method, params.clone());
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
|
||||
fn exec(&mut self, command: &str, params: Value) -> Value {
|
||||
self.ok("engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
fn click(&mut self, id: &str) {
|
||||
self.ok("ui.click", json!({"id": id}));
|
||||
self.frames(2);
|
||||
}
|
||||
|
||||
fn element(&mut self, id: &str) -> Option<[f32; 4]> {
|
||||
let v = self.ok("ui.elements", json!({"prefix": id}));
|
||||
v.as_array()?.iter().find(|e| e["id"] == id).map(|e| {
|
||||
let r = &e["rect"];
|
||||
[r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32]
|
||||
})
|
||||
}
|
||||
|
||||
fn ids(&mut self, prefix: &str) -> Vec<String> {
|
||||
let v = self.ok("ui.elements", json!({"prefix": prefix}));
|
||||
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
fn has(&mut self, id: &str) -> bool {
|
||||
self.ids(id).iter().any(|i| i == id)
|
||||
}
|
||||
|
||||
/// Press on `id`, move by (`dx`, `dy`) over `steps` frames, release.
|
||||
fn drag(&mut self, id: &str, dx: f32, dy: f32, steps: usize) {
|
||||
let r = self.element(id).unwrap_or_else(|| panic!("no element {id}"));
|
||||
let mut p = egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
||||
let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e);
|
||||
push(self, egui::Event::PointerMoved(p));
|
||||
self.frames(1);
|
||||
push(self, egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
self.frames(1);
|
||||
for _ in 0..steps {
|
||||
p.x += dx / steps as f32;
|
||||
p.y += dy / steps as f32;
|
||||
push(self, egui::Event::PointerMoved(p));
|
||||
self.frames(1);
|
||||
}
|
||||
push(self, egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
self.frames(2);
|
||||
}
|
||||
|
||||
fn strip(&mut self, r: &str) -> Value {
|
||||
let m = self.exec("mixer.inspect", json!({}));
|
||||
m["strips"].as_array().unwrap().iter().find(|s| s["ref"] == r).cloned().unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn undo_len(&mut self) -> usize {
|
||||
self.exec("history.list", json!({}))["undo"].as_array().unwrap().len()
|
||||
}
|
||||
|
||||
fn snapshot(&mut self, name: &str) {
|
||||
let Some(dir) = self.snapshots.clone() else { return };
|
||||
self.frames(2);
|
||||
match self.harness.render() {
|
||||
Ok(img) => {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(format!("{name}.png"));
|
||||
img.save(&path).unwrap();
|
||||
eprintln!("snapshot: {}", path.display());
|
||||
}
|
||||
Err(e) => eprintln!("snapshot {name} skipped: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surround_panner_puck_and_six_channel_meters() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Audio"}));
|
||||
d.frames(3);
|
||||
assert!(d.has("mixer.A1.pan") && !d.has("mixer.A1.pan51"), "stereo Mix: pan knob");
|
||||
d.exec("sequence.settings", json!({"mix": "5.1"}));
|
||||
d.frames(3);
|
||||
assert!(d.has("mixer.A1.pan51"), "5.1 Mix: the puck replaces the knob");
|
||||
assert!(!d.has("mixer.A1.pan"));
|
||||
for id in ["mixer.A1.pan51.center", "mixer.A1.pan51.lfe"] {
|
||||
assert!(d.has(id), "{id} missing");
|
||||
}
|
||||
// drag the puck to the left and towards the rear: one undo step, both lanes move
|
||||
let u0 = d.undo_len();
|
||||
d.drag("mixer.A1.pan51", -14.0, 10.0, 6);
|
||||
let a1 = d.strip("A1");
|
||||
let (x, y) = (a1["pan51"]["x"].as_f64().unwrap(), a1["pan51"]["y"].as_f64().unwrap());
|
||||
assert!(x < -20.0, "puck moved left: {a1}");
|
||||
assert!(y < 80.0, "puck moved back: {a1}");
|
||||
assert!(d.undo_len() > u0);
|
||||
// double-click returns to front centre
|
||||
d.ok("ui.click", json!({"id": "mixer.A1.pan51", "double": true}));
|
||||
d.frames(2);
|
||||
let a1 = d.strip("A1");
|
||||
if a1["pan51"]["x"].as_f64() != Some(0.0) {
|
||||
// controls without double-click support in this build of the control channel: set by command
|
||||
d.exec("mixer.setValue", json!({"strip": "A1", "lane": "pan51.x", "value": 0.0}));
|
||||
}
|
||||
// Center % and LFE from commands show on the strip
|
||||
d.exec("mixer.setValue", json!({"strip": "A1", "lane": "pan51.center", "value": 40.0}));
|
||||
d.frames(2);
|
||||
let ids = d.ok("ui.elements", json!({"prefix": "mixer.A1.pan51.center"}));
|
||||
assert!(ids.to_string().contains("Center 40 %"), "{ids}");
|
||||
// the Mix meter has six bars (label lists six values)
|
||||
let m = d.ok("ui.elements", json!({"prefix": "mixer.Mix.meter"}));
|
||||
let label = m[0]["label"].as_str().unwrap_or("").to_string();
|
||||
assert_eq!(label.matches('/').count(), 5, "six channel levels: {label}");
|
||||
d.snapshot("audio51-track-mixer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_hide_tracks_and_meter_input_only() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Audio"}));
|
||||
d.frames(3);
|
||||
assert!(d.has("mixer.A2.fader"));
|
||||
d.click("mixer.menu");
|
||||
d.click("mixer.menu.showHide");
|
||||
d.frames(2);
|
||||
assert!(d.has("mixer.showHide.A2"), "{:?}", d.ids("mixer.showHide"));
|
||||
d.click("mixer.showHide.A2");
|
||||
assert!(!d.has("mixer.A2.fader"), "A2 hidden");
|
||||
assert!(d.has("mixer.A1.fader") && d.has("mixer.Mix.fader"));
|
||||
let st = d.ok("ui.inspect", json!({}));
|
||||
assert!(st.to_string().contains("mixer_hidden") || st.to_string().contains("mixerHidden"), "UI state is inspectable");
|
||||
d.click("mixer.showHide.all");
|
||||
d.click("mixer.showHide.ok");
|
||||
assert!(d.has("mixer.A2.fader"), "shown again");
|
||||
// Meter Input(s) Only: a UI toggle in the panel menu
|
||||
d.click("mixer.menu");
|
||||
d.click("mixer.menu.meterInputOnly");
|
||||
let st = d.ok("ui.inspect", json!({})).to_string();
|
||||
assert!(st.contains("\"mixer_meter_input_only\":true") || st.contains("\"mixerMeterInputOnly\":true"), "{st}");
|
||||
// the transport record button is wired to voice-over recording
|
||||
assert!(d.has("mixer.transport.voiceover.recordToggle"));
|
||||
d.snapshot("audio51-show-hide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_mixer_mode_records_clip_keyframes() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.panel.show", json!({"panel": "AudioClipMixer"}));
|
||||
d.frames(3);
|
||||
assert!(d.has("clipMixer.A1.mode"));
|
||||
d.click("clipMixer.A1.mode");
|
||||
d.click("clipMixer.A1.mode.Latch");
|
||||
let m = d.exec("mixer.inspect", json!({}));
|
||||
assert_eq!(m["clipModes"][0]["mode"], "Latch");
|
||||
// an automation pass: the fader drag is a live gesture, written as clip keyframes at the end
|
||||
d.exec("playhead.set", json!({"seconds": 1.0}));
|
||||
d.exec("mixer.recordStart", json!({}));
|
||||
d.drag("clipMixer.A1.fader", 0.0, 40.0, 5);
|
||||
d.exec("playhead.set", json!({"seconds": 2.5}));
|
||||
let r = d.exec("mixer.recordStop", json!({}));
|
||||
assert_eq!(r["lanes"], 1, "{r}");
|
||||
assert_eq!(r["written"][0]["clips"], true, "{r}");
|
||||
let seq = d.exec("sequence.inspect", json!({}));
|
||||
let fx = &seq["audio"][0]["items"][0]["effects"];
|
||||
let vol = fx.as_array().unwrap().iter().find(|e| e["effect"] == "volume").unwrap();
|
||||
assert!(vol["params"]["level"]["keyframes"].as_u64().unwrap_or(0) >= 2, "clip keyframes written: {vol}");
|
||||
d.snapshot("audio51-clip-mixer");
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
//! Headless UI tests of Clip ▸ Remix: the menu entries, the Remix Properties dialog and the Remix
|
||||
//! tool (drag a music clip's Out edge), driven by automation id through the control channel.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const SR: u32 = 48_000;
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn new(session: Session) -> Self {
|
||||
let (tx, rx) = channel();
|
||||
let app = FilmcraftApp::new(session).with_control(rx);
|
||||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx };
|
||||
d.frames(4);
|
||||
d
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
let ctx = self.harness.ctx.clone();
|
||||
let mut raw = std::mem::take(self.harness.input_mut());
|
||||
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||
*self.harness.input_mut() = raw;
|
||||
self.harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||
self.tx.send(req).unwrap();
|
||||
for _ in 0..600 {
|
||||
self.frames(1);
|
||||
if let Ok(v) = reply.try_recv() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
|
||||
fn ok(&mut self, method: &str, params: Value) -> Value {
|
||||
let v = self.call(method, params.clone());
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
|
||||
fn exec(&mut self, command: &str, params: Value) -> Value {
|
||||
self.ok("engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
fn ids(&mut self, prefix: &str) -> Vec<String> {
|
||||
let v = self.ok("ui.elements", json!({"prefix": prefix}));
|
||||
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` over several frames (as a mouse does), release. Use steps of a
|
||||
/// few pixels: the drag starts where the pointer has moved past egui's drag threshold, and edge
|
||||
/// hits are 7 px wide.
|
||||
fn drag(&mut self, from: egui::Pos2, to: egui::Pos2, steps: usize) {
|
||||
let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e);
|
||||
push(self, egui::Event::PointerMoved(from));
|
||||
self.frames(1);
|
||||
push(self, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
self.frames(1);
|
||||
for i in 1..=steps {
|
||||
push(self, egui::Event::PointerMoved(from + (to - from) * (i as f32 / steps as f32)));
|
||||
self.frames(1);
|
||||
}
|
||||
push(self, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
self.frames(2);
|
||||
}
|
||||
|
||||
fn duration(&mut self, clip: u64) -> f64 {
|
||||
let s = self.harness.state();
|
||||
s.session.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.duration.seconds()
|
||||
}
|
||||
}
|
||||
|
||||
/// A session with 32 s of generated 120 BPM music on A1 (selected).
|
||||
fn session() -> (Session, u64) {
|
||||
let (m, _) = filmcraft_audio_dsp::remix::test_music(SR, 120.0, 16, 5);
|
||||
let mut s = Session::default();
|
||||
s.execute("file.newSequence", json!({"name": "remix", "audio": 2, "video": 1})).unwrap();
|
||||
let inter: Vec<f32> = (0..m[0].len()).flat_map(|i| [m[0][i], m[1][i]]).collect();
|
||||
let bytes: Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, SR).into();
|
||||
let item = filmcraft_engine::commands::import_bytes(&mut s, "/music.wav", bytes, None).unwrap();
|
||||
let r = s.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap();
|
||||
let c = r["clips"][0].as_u64().unwrap();
|
||||
s.execute("timeline.select", json!({"clips": [c]})).unwrap();
|
||||
(s, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remix_menu_and_properties_dialog() {
|
||||
let (s, clip) = session();
|
||||
let mut d = Driver::new(s);
|
||||
// the menu lists the Remix submenu
|
||||
let items = d.ok("ui.menu.list", json!({}));
|
||||
let ids: Vec<&str> = items.as_array().unwrap().iter().filter_map(|i| i["id"].as_str()).collect();
|
||||
for id in ["clip.remix.enable", "clip.remix.properties", "clip.remix.revert"] {
|
||||
assert!(ids.contains(&id), "{id} missing from the menus");
|
||||
}
|
||||
let original = d.duration(clip);
|
||||
// properties before enabling: refused with a reason
|
||||
let v = d.call("ui.menu.invoke", json!({"id": "clip.remix.properties"}));
|
||||
assert_eq!(v["ok"], json!(false));
|
||||
d.ok("ui.menu.invoke", json!({"id": "clip.remix.enable"}));
|
||||
let r = d.ok("ui.menu.invoke", json!({"id": "clip.remix.properties"}));
|
||||
assert_eq!(r["dialog"], "remixProperties");
|
||||
d.frames(3);
|
||||
let ids = d.ids("remixProperties.");
|
||||
for id in ["duration", "segments", "variations", "info", "ok", "cancel"] {
|
||||
assert!(ids.contains(&format!("remixProperties.{id}")), "remixProperties.{id} missing: {ids:?}");
|
||||
}
|
||||
// Cancel changes nothing
|
||||
let before = d.duration(clip);
|
||||
d.ok("ui.click", json!({"id": "remixProperties.cancel"}));
|
||||
d.frames(3);
|
||||
assert!(d.ids("remixProperties.").is_empty());
|
||||
assert_eq!(d.duration(clip), before);
|
||||
// type a target duration and press OK: one undo step, within a beat of the target
|
||||
d.ok("ui.menu.invoke", json!({"id": "clip.remix.properties"}));
|
||||
d.frames(3);
|
||||
d.ok("ui.click", json!({"id": "remixProperties.duration"}));
|
||||
d.frames(2);
|
||||
d.ok("ui.key", json!({"key": "Cmd+A"}));
|
||||
d.ok("ui.type", json!({"text": "20"}));
|
||||
d.ok("ui.key", json!({"key": "Enter"}));
|
||||
d.frames(3);
|
||||
let undo_before = d.harness.state().session.history.undo.len();
|
||||
d.ok("ui.click", json!({"id": "remixProperties.ok"}));
|
||||
d.frames(3);
|
||||
let after = d.duration(clip);
|
||||
assert!((after - 20.0).abs() <= 0.5, "remixed to {after} s");
|
||||
assert_eq!(d.harness.state().session.history.undo.len(), undo_before + 1);
|
||||
let p = d.exec("clip.remix.properties", json!({"clip": clip}));
|
||||
assert_eq!(p["targetSeconds"].as_f64().unwrap(), 20.0);
|
||||
// Revert Remix from the menu
|
||||
d.ok("ui.menu.invoke", json!({"id": "clip.remix.revert"}));
|
||||
assert_eq!(d.duration(clip), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remix_tool_drag_sets_the_duration() {
|
||||
let (s, clip) = session();
|
||||
let mut d = Driver::new(s);
|
||||
d.ok("ui.menu.invoke", json!({"id": "tool.remix"}));
|
||||
let ui = d.ok("ui.inspect", json!({}));
|
||||
assert_eq!(ui["ui"]["tool"], "Remix");
|
||||
// the tool sits in the Rate Stretch group of the Tools panel
|
||||
d.frames(2);
|
||||
let edge = d.ok("ui.timeline.locate", json!({"clip": clip, "edge": "out"}));
|
||||
let (x, y) = (edge["x"].as_f64().unwrap(), edge["y"].as_f64().unwrap());
|
||||
let hit = d.ok("ui.timeline.hit", json!({"x": x - 1.0, "y": y}));
|
||||
assert!(hit.to_string().contains("out") || hit.to_string().contains("Out"), "{hit}");
|
||||
// drag the Out edge to the left by a third of the clip
|
||||
let start = d.ok("ui.timeline.locate", json!({"clip": clip, "edge": "in"}));
|
||||
let w = x - start["x"].as_f64().unwrap();
|
||||
let undo_before = d.harness.state().session.history.undo.len();
|
||||
d.drag(egui::pos2(x as f32 - 0.5, y as f32), egui::pos2((x - 0.5 - w / 3.0) as f32, y as f32), 300);
|
||||
d.frames(4);
|
||||
let dur = d.duration(clip);
|
||||
assert!((dur - 32.0 * 2.0 / 3.0).abs() < 1.5, "remixed to {dur} s");
|
||||
assert_eq!(d.harness.state().session.history.undo.len(), undo_before + 1, "one undo step");
|
||||
let p = d.exec("clip.remix.properties", json!({"clip": clip}));
|
||||
assert_eq!(p["remixed"], true);
|
||||
assert!((p["seconds"].as_f64().unwrap() - p["targetSeconds"].as_f64().unwrap()).abs() <= 0.5);
|
||||
assert!(!p["cuts"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
//! Headless UI tests of voice-over recording: the audio track header's microphone button, its
|
||||
//! right-click Voice-Over Record Settings dialog, and a recording with the synthetic input landing
|
||||
//! on the clicked track.
|
||||
|
||||
use std::sync::mpsc::{Sender, channel};
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::control::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
harness: Harness<'static, FilmcraftApp>,
|
||||
tx: Sender<ControlRequest>,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
fn demo() -> Self {
|
||||
let mut session = Session::default();
|
||||
session.execute("file.openDemoProject", json!({})).expect("demo project");
|
||||
let (tx, rx) = channel();
|
||||
let app = FilmcraftApp::new(session).with_control(rx);
|
||||
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
|
||||
let mut d = Driver { harness, tx };
|
||||
d.frames(4);
|
||||
d
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
let ctx = self.harness.ctx.clone();
|
||||
let mut raw = std::mem::take(self.harness.input_mut());
|
||||
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
|
||||
*self.harness.input_mut() = raw;
|
||||
self.harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(&mut self, method: &str, params: Value) -> Value {
|
||||
let (req, reply) = ControlRequest::new(method, params.clone());
|
||||
self.tx.send(req).unwrap();
|
||||
for _ in 0..600 {
|
||||
self.frames(1);
|
||||
if let Ok(v) = reply.try_recv() {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
panic!("no reply to {method} {params}");
|
||||
}
|
||||
|
||||
fn ok(&mut self, method: &str, params: Value) -> Value {
|
||||
let v = self.call(method, params.clone());
|
||||
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
|
||||
v["result"].clone()
|
||||
}
|
||||
|
||||
fn exec(&mut self, command: &str, params: Value) -> Value {
|
||||
self.ok("engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
fn click(&mut self, id: &str) {
|
||||
self.ok("ui.click", json!({"id": id}));
|
||||
self.frames(2);
|
||||
}
|
||||
|
||||
fn ids(&mut self, prefix: &str) -> Vec<String> {
|
||||
let v = self.ok("ui.elements", json!({"prefix": prefix}));
|
||||
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
|
||||
}
|
||||
|
||||
fn settings(&mut self) -> Value {
|
||||
self.exec("audio.voiceover.settings", json!({}))["settings"].clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_dialog_from_the_track_header_persists_on_ok() {
|
||||
let mut d = Driver::demo();
|
||||
assert!(d.ids("timeline.track.A1.voiceover").contains(&"timeline.track.A1.voiceover".to_string()));
|
||||
// right-click the microphone: the context menu has the settings entry
|
||||
d.ok("ui.click", json!({"id": "timeline.track.A1.voiceover", "button": "right"}));
|
||||
d.frames(2);
|
||||
d.click("timeline.track.A1.voiceover.settings");
|
||||
let ids = d.ids("voiceover.");
|
||||
for id in [
|
||||
"voiceover.name",
|
||||
"voiceover.source",
|
||||
"voiceover.input",
|
||||
"voiceover.countdown",
|
||||
"voiceover.preroll",
|
||||
"voiceover.postroll",
|
||||
"voiceover.ok",
|
||||
"voiceover.cancel",
|
||||
] {
|
||||
assert!(ids.iter().any(|x| x == id), "{id} missing in {ids:?}");
|
||||
}
|
||||
// edit: countdown off, name appended, input channel 2 from the dropdown
|
||||
d.click("voiceover.countdown");
|
||||
d.click("voiceover.name");
|
||||
d.ok("ui.type", json!({"text": " Take"}));
|
||||
d.frames(2);
|
||||
d.click("voiceover.input");
|
||||
d.click("voiceover.input.1");
|
||||
d.click("voiceover.ok");
|
||||
assert!(d.ids("voiceover.ok").is_empty(), "the dialog closed");
|
||||
let s = d.settings();
|
||||
assert_eq!(s["countdownSoundCues"], false);
|
||||
assert_eq!(s["name"], "Voice-over Take");
|
||||
assert_eq!(s["inputChannel"], 1);
|
||||
// Cancel discards edits (opened through the UI command this time)
|
||||
d.ok("ui.menu.invoke", json!({"id": "voiceover.settingsDialog"}));
|
||||
d.frames(2);
|
||||
d.click("voiceover.countdown");
|
||||
d.click("voiceover.cancel");
|
||||
assert!(d.ids("voiceover.ok").is_empty());
|
||||
assert_eq!(d.settings()["countdownSoundCues"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn microphone_button_records_onto_its_track() {
|
||||
let mut d = Driver::demo();
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-vo-ui-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
d.exec("file.projectSettings.scratchDisks", json!({"captured": dir.to_string_lossy()}));
|
||||
d.exec("audio.voiceover.settings", json!({"prerollSeconds": 0.5}));
|
||||
let (n, label) = (2usize, "A2".to_string());
|
||||
d.exec("playhead.set", json!({"seconds": 2.0}));
|
||||
d.click(&format!("timeline.track.{label}.voiceover"));
|
||||
let st = d.settings();
|
||||
let rec = d.exec("audio.voiceover.settings", json!({}))["recording"].clone();
|
||||
assert!(rec.is_object(), "recording started: {st} {rec}");
|
||||
let (start, capture) = (rec["recordStart"].as_i64().unwrap(), rec["captureStart"].as_i64().unwrap());
|
||||
// half a second of pre-roll; playback starts on the frame at or before the capture start (the
|
||||
// playhead snaps to frames) and the capture restarts there when the audio clock starts
|
||||
let frame = 10_594_584_000; // 1/23.976 s
|
||||
assert!((127_008_000_000..=127_008_000_000 + frame).contains(&(start - capture)), "{}", start - capture);
|
||||
assert_eq!(d.ok("ui.inspect", json!({}))["playback"]["playing"], json!(true));
|
||||
// playing: the countdown / recording overlay is up
|
||||
assert!(!d.ids("voiceover.countdown.overlay").is_empty());
|
||||
// stop at 3 s (headless frames don't advance the playback clock; the engine command takes the
|
||||
// time) and look at the result on the record track
|
||||
let v = d.exec("audio.voiceover.stop", json!({"time": start + 254_016_000_000}));
|
||||
assert_eq!(v["placed"], true);
|
||||
assert_eq!(v["track"], label.as_str());
|
||||
assert_eq!(v["samples"], 48_000);
|
||||
d.ok("ui.playback", json!({"action": "stop"}));
|
||||
let q = d.exec("sequence.inspect", json!({}));
|
||||
let clips = q["audio"][n - 1]["items"].as_array().cloned().unwrap_or_default();
|
||||
assert!(clips.iter().any(|c| c["start"] == json!(start) && c["duration"] == json!(254_016_000_000i64) && c["clip"] == v["clip"]), "{q}");
|
||||
assert!(d.exec("audio.voiceover.settings", json!({}))["recording"].is_null());
|
||||
// clicking the microphone while recording stops the take (and playback)
|
||||
d.click(&format!("timeline.track.{label}.voiceover"));
|
||||
assert!(d.exec("audio.voiceover.settings", json!({}))["recording"].is_object());
|
||||
d.click(&format!("timeline.track.{label}.voiceover"));
|
||||
assert!(d.exec("audio.voiceover.settings", json!({}))["recording"].is_null());
|
||||
assert_eq!(d.ok("ui.inspect", json!({}))["playback"]["playing"], json!(false));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ and `filmcraft-cli`.
|
|||
| `frame` | L1 | `VideoFrame` (planar YUV / RGBA8 / linear RGBA f32, colour metadata), `AudioBuffer` |
|
||||
| `media` | L1 | `MediaSource` trait, probing/openers, frame cache, generators, stills, image sequences, WAV / Broadcast WAV |
|
||||
| `project` | L1 | document model, effect definitions, keyframes |
|
||||
| `audio-dsp` | L1 | loudness metering (BS.1770 / R128) and audio effects; no dependencies |
|
||||
| `audio-dsp` | L1 | loudness metering (BS.1770 / R128), audio effects, channel layouts (BS.775 up/downmix, 5.1 panner), music remix analysis; no dependencies |
|
||||
| `text` | L1 | text engine: font database (bundled OFL fonts + system fonts), shaping (harfrust), bidi, line breaking, paragraph layout, glyph/path rasteriser, strokes ([crates/text/README.md](../crates/text/README.md)) |
|
||||
| `edit` | L2 | pure edit algebra (insert, overwrite, razor, ripple, roll, slip, slide, rate stretch…; text-based editing: `edit::transcript`) |
|
||||
| `speech` | L2 | speech-to-text: `Transcriber` trait, Whisper model catalogue + verified downloader (feature `download`), pure-Rust Whisper inference on candle with word timestamps (feature `whisper`), speaker labelling ([transcripts.md](transcripts.md)) |
|
||||
|
|
@ -292,8 +292,73 @@ Mix: bus sum → pre-fader inserts → fader → meter → post-fader inserts
|
|||
thinning, optional minimum time interval) and written over its time range as one undo step,
|
||||
with boundary keyframes that keep the automation outside the range unchanged.
|
||||
- **Live state.** `PreviewStore::live` (`LiveMix`) also carries per-strip meter peaks posted by the
|
||||
mix (Track Mixer and Audio Meters read them) and the newest project snapshot, which the audio
|
||||
callback uses, so edits made during playback are heard.
|
||||
mix (one per channel; Track Mixer and Audio Meters read them), the voice-over input level (Meter
|
||||
Input(s) Only) and the newest project snapshot, which the audio callback uses, so edits made
|
||||
during playback are heard.
|
||||
- **Audio Clip Mixer automation.** Each audio track has a Clip Mixer mode (`clipMixer.setMode`,
|
||||
session state `EditorState::clip_mixer_modes`, Read by default). During a pass the Clip Mixer's
|
||||
fader and pan knob are gestures on the track's `clip.volume` / `clip.pan` lanes
|
||||
(`clipMixer.touch` / `clipMixer.release`): the clip under the playhead plays the held value at
|
||||
once (`render::audio::track_input_live`), and Latch / Touch / Write follow the track rules. At
|
||||
`mixer.recordStop` each stream is thinned and written as **clip keyframes** (Volume `level`,
|
||||
Panner `balance`, in media time) into every clip it covers, with boundary keyframes that keep the
|
||||
clip's values outside the range; one undo step for the whole pass.
|
||||
- **Track Mixer view.** The panel menu (`mixer.menu`) has Show/Hide Tracks (`UiState::mixer_hidden`)
|
||||
and Meter Input(s) Only (`UiState::mixer_meter_input_only`: record-armed tracks meter the
|
||||
voice-over input). The transport's record button runs `voiceover.recordToggle`.
|
||||
|
||||
### 5.1.1 Multichannel and 5.1
|
||||
|
||||
```text
|
||||
5.1 track (6 ch: L R C LFE Ls Rs) ─┐ ┌─► 5.1 Mix ─► 6-ch export / 5.1 device
|
||||
stereo / mono track ──5.1 panner───┼─► 5.1 submix / 5.1 Mix ─┤
|
||||
5.1 strip ──BS.775 downmix─────────┴─► stereo bus └─► stereo device: 5.1 Mixdown Type fold
|
||||
```
|
||||
|
||||
- **Model.** `Track::channels` (Standard = Stereo, Mono, 5.1, Adaptive) and
|
||||
`SequenceSettings::audio_master` (the Mix: Stereo, Mono, 5.1, Adaptive) existed already;
|
||||
`file.newSequence {mix, trackType}` and `sequence.settings {mix}` set them, `mixer.setStrip
|
||||
{channels}` / `mixer.addSubmix {channels}` per strip. The 5.1 panner is four lanes in
|
||||
`MixerStrip::lanes` (`pan51.x`, `pan51.y` −100…100, `pan51.center` 0…100 %, `pan51.lfe` dB): a
|
||||
lane without keyframes stores the static value, so no new project fields were needed and the
|
||||
puck is automatable like any lane.
|
||||
- **Maths** (`audio_dsp::channels`). 5.1 order is L, R, C, LFE, Ls, Rs everywhere inside FilmCraft.
|
||||
Downmix is ITU-R BS.775 (`Lo = L + k·C + k·Ls`, `Ro = R + k·C + k·Rs`, mono
|
||||
`M = k·L + k·R + C + ½·Ls + ½·Rs`, `k = 1/√2`, LFE omitted); upmix puts mono on C and stereo on
|
||||
L/R. The panner splits a point source front/rear and left/right with the sine/cosine
|
||||
constant-power law and gives the centre speaker `center·(1 − |x|)` of the front power (Σg² = 1).
|
||||
Stereo sources sit at the puck ± 1 in x; 5.1 sources keep their layout moved by the puck's offset
|
||||
from front-centre (the default puck is the identity). LFE gets the source LFE (5.1) or the mean of
|
||||
the source channels (mono/stereo, default −∞) times the LFE level.
|
||||
- **Graph.** Buses are 2 or 6 channels wide (`render::mixer::width_of`); inserts are built for the
|
||||
strip's width; routes convert between widths (5.1 panner into 5.1 buses, BS.775 into stereo
|
||||
buses); meters have one value per channel. `mix_graph` returns the Mix's width;
|
||||
`audio::mix_sequence` always returns stereo, `mix_sequence_layout` any layout and mixdown. Clips
|
||||
on 5.1 tracks play the source's first six channels (or the six picked with Modify ▸ Audio
|
||||
Channels), mono/stereo sources upmixed; clip effects run on all six channels.
|
||||
- **Playback.** On a device with ≥ 6 output channels a 5.1 Mix plays as six channels; on a stereo
|
||||
device it is folded with Preferences ▸ Audio ▸ 5.1 Mixdown Type (Front Only, Front + Rear
|
||||
Surround = BS.775, Front + LFE, Front + Rear Surround + LFE; `PreviewStore::mix_layout`).
|
||||
- **Export.** Audio Channels Mono / Stereo / 5.1: WAV writes `WAVE_FORMAT_EXTENSIBLE` with channel
|
||||
mask `0x3F`; QuickTime PCM (ProRes, DNxHR, MJPEG) adds a `chan` atom
|
||||
(`kAudioChannelLayoutTag_MPEG_5_1_A`); AAC uses channel configuration 6 (our encoder already
|
||||
supported 1–8 channels; the exporter reorders to AAC's C, L, R, Ls, Rs, LFE). A stereo export of a
|
||||
5.1 Mix is the BS.775 downmix.
|
||||
|
||||
### 5.1.2 Voice-over recording
|
||||
|
||||
**Voice-over recording** (`engine::voiceover`, `audio.voiceover.*`). The record point R is the playhead, or the In point when In/Out are set (punch-in; punch-out at Out). Playback starts the pre-roll before it (C = max(0, R − pre-roll)) and the input is captured from C. When the UI's audio clock really starts, `audio.voiceover.sync` restarts the capture there. `audio.voiceover.stop` keeps the audio from R to min(stop, Out), writes a mono 32-bit float WAV `<Name> <n>.wav` (Scratch Disks ▸ Captured, else next to the project, else the data or temporary directory), imports it and overwrites it onto the record track at R as one undo step. The record track is the given one, else the record-armed one, else the first targeted one. Input goes through the `AudioInput` trait: cpal in the desktop app (`apps/filmcraft/src/audio_in.rs`, on its own thread), and `SyntheticInput` headless, which produces exactly the samples the timeline asks for so recordings land sample-accurately. Voice-Over Record Settings (Source, Input channel, Name, Countdown Sound Cues, Pre-/Post-roll) are preferences (`voiceOver`). The track header's microphone records or stops, and a right-click opens the settings dialog. Countdown beeps (1 kHz, 100 ms, each whole second of pre-roll and at R) are mixed into playback, an overlay counts down, and playback stops at Out + post-roll.
|
||||
|
||||
### 5.1.3 Remix
|
||||
|
||||
```text
|
||||
clip media (source In … +duration) ─► audio_dsp::remix::analyze: spectral-flux onsets → tempo (autocorrelation, 50–200 BPM)
|
||||
→ DP beat tracker → per-beat chroma + mel cepstra → beat self-similarity
|
||||
─► plan(target, Segments, Variations): intro … joints at beat boundaries … outro, length within one beat
|
||||
─► hidden clip effect `remix` (target, sliders, original duration, plan in ticks) ─► render::remix::read (20 ms equal-power crossfades)
|
||||
```
|
||||
|
||||
Clip ▸ Remix ▸ Enable Remix / Remix Properties… / Revert Remix (`clip.remix.*`; agents can use `clip.remix {duration|seconds…}`) and the Remix tool in the Rate Stretch group (drag a music clip's Out edge) retime a music clip to a target duration. The analysis finds the beat and compares every pair of beats by harmony (chroma) and timbre (cepstra). Each joint leaves the music at one beat boundary and continues at the beat boundary whose four beats on either side sound most alike. The intro and outro are kept, and the result lands within one beat of the target. Segments (0–100) sets the number of joints (1–3, more for long extensions). Variations (0–100) sets how far a joint may move from its evenly spaced position (±1–8 beats). The state is a hidden effect instance with no definition, so panels, the effect chain and Paste Attributes ignore it. The plan is stored in ticks, so playback and export read it without re-analysing. A remix never moves other clips: one that would overlap the next clip is refused, and Revert restores the original duration. Results are deterministic and the analysis is cached per media range.
|
||||
|
||||
### 5.2 Essential Sound
|
||||
|
||||
|
|
@ -325,8 +390,7 @@ essentialSound.generateDucking trigger clips' summed level (10 ms hops) → ac
|
|||
fade-down before it and a fade-up after it (`audio_dsp::ducking::duck_keyframes`), written as Volume
|
||||
level keyframes that replace earlier ones.
|
||||
- **Presets** (our own names and values) per type; user presets are saved in preferences
|
||||
(`essentialSound.userPresets`). Music remixing to a duration is out of scope (the Duration section
|
||||
says so).
|
||||
(`essentialSound.userPresets`). Music remixing to a duration is Clip ▸ Remix (§5.1.3).
|
||||
- **Enhance Speech** is a DSP chain (high-pass, de-mud, presence and air EQ, expander, compressor), not a
|
||||
model: no speech-enhancement model with an open licence that we could ship and verify is bundled.
|
||||
DeepFilterNet (MIT/Apache-2.0, Rust inference via tract) is the candidate for a future optional
|
||||
|
|
@ -461,13 +525,13 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
|
|||
| `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) |
|
||||
| `mjpeg` | built in | MOV |
|
||||
| `png`, `tiff`, `bmp` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
|
||||
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE / AIFF (16- or 24-bit PCM) |
|
||||
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) |
|
||||
|
||||
- **Settings.** `ExportSettings` (serde, camelCase, every field optional) holds Video (frame size or
|
||||
Match Source, frame rate, Scale to Fit / Fill / Stretch, pixel aspect, field order — progressive
|
||||
only, profile / level, bitrate encoding, target / maximum / adaptive bitrate, keyframe distance,
|
||||
maximum render quality), Audio (codec, sample rate, channels, AAC bitrate, PCM sample size),
|
||||
Multiplexer, Captions (burn-in or SRT / WebVTT sidecar), Effects (image / name / timecode
|
||||
Multiplexer (audio channels: mono, stereo or 5.1, §5.1.1), Captions (burn-in or SRT / WebVTT sidecar), Effects (image / name / timecode
|
||||
overlays, video limiter, loudness normalization), Metadata (`udta` `©nam`, `©ART`, `©cpy`, `©des`,
|
||||
`©cmt`). `settings.summary()` / `estimate_bytes()` feed Export mode's Summary.
|
||||
- **Presets.** `export::presets::builtin_presets()` are our own definitions (Match Source adaptive
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails
|
|||
| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost |
|
||||
| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id |
|
||||
| Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor |
|
||||
| 5.1 / multichannel | `crates/audio-dsp/src/channels.rs`, `crates/render/src/mixer_tests.rs`, `crates/export/src/surround_tests.rs`, `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | BS.775 downmix / upmix coefficients exact, mixdown types; 5.1 panner point gains equal-power (Σg² = 1 over a 21×21 grid × 3 centre settings), speaker positions exact, 5.1 through the default puck bit-exact identity; graph: stereo/mono tracks into a 5.1 Mix (L/R, C, rear right, room centre −6 dB each, LFE = mean), 5.1 track through a 5.1 Mix and folded into stereo, a 5.1 submix, `pan51` automation block-invariant, six-channel meters; export: 6-ch WAV (`WAVE_FORMAT_EXTENSIBLE`, mask 0x3F) and ProRes MOV (6 × 24-bit PCM) decoded by ffmpeg **sample-exact**, ffprobe `channels` 6 / `channel_layout` 5.1; AAC 5.1 in MP4: every decoded channel's own tone ≥ 30 dB above the others' (no swapped channels), stereo export of a 5.1 Mix = BS.775 within 2·10⁻⁶; `sequence.settings {mix}`, `file.newSequence {mix, trackType}`, panner lanes, Write pass records the puck; UI: puck drag (one undo step), Center / LFE, six meter bars |
|
||||
| Audio Clip Mixer automation, Track Mixer view | `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | Touch: a 201-point fader ramp thins to ≤ 6 clip keyframes, values within 0.1 dB, live override while held, values outside the gesture unchanged, one undo step; Latch holds until stop; Write records volume and pan from the start; a gesture across two clips writes both; Read commits once without a pass; UI: Clip Mixer mode dropdown, a recorded fader drag writes clip keyframes; Show/Hide Tracks, Meter Input(s) Only, transport record button |
|
||||
| Voice-over | `crates/engine/src/voiceover_tests.rs`, `crates/ui-egui/tests/voiceover_ui.rs` | synthetic click train lands in `mix_sequence` exactly at R + k·period (pre-roll dropped, exact sample count), punch-in between In/Out (ramp source, every sample checked), one undo step + undo/redo, take numbering, record-arm/targeting track choice, sync/discard/zero-length/locked cases, cue times; UI: right-click Mic → Voice-Over Record Settings dialog (edits persist on OK, Cancel discards), the Mic button starts and stops a take on its track with pre-roll and overlay |
|
||||
| Remix | `crates/audio-dsp/src/remix.rs`, `crates/render/src/remix.rs`, `crates/engine/src/remix_tests.rs`, `crates/ui-egui/tests/remix_ui.rs` | generated rhythmic music (Rust-synthesised drums, bass, chord sections at 90–128 BPM, 22.05/44.1/48 kHz): tempo within 0.3 BPM, every beat within 10 ms (measured ≤ 8.9 ms); remix targets from 0.4× to 2.2× the source within one beat (measured ≤ 12 ms at default sliders), every cut on a detected beat, intro and outro kept, pieces ≥ 4 beats, deterministic; silence, tone and noise refused; rendering independent of request cuts, source-exact outside the 20 ms equal-power crossfades, no clicks; the engine's mix equals the source pieces (< 1e-5); undo, redo and revert exact; overlaps refused; Remix Properties dialog and Remix tool drag driven by automation id |
|
||||
| Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) |
|
||||
| Robustness / fuzz | `*/tests/robustness.rs`, `*/tests/fuzz.rs` | seeded mutation and truncation of real and synthetic files; nothing may panic |
|
||||
| Performance | `*/tests/perf.rs` (`#[ignore]`) | §5 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue