mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
M11.2: audio playback + waveforms + audio effects
- Audio effects (Backwards, Bass & Treble, Delay, Flange & Chorus, High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer, Tone) applied per layer in the comp mixdown, so export and preview share them. RBJ-cookbook biquads, Schroeder/Moorer reverb, modulated delay lines; stateless per block via pre-roll. Tone makes any layer audible. - Real-time audio preview: ui-egui AudioFeed/AudioPlayback (feeder thread mixing ahead) behind an AudioDevice hook; the desktop app implements it with cpal (L6). Audio clock drives playback (consumed frames minus device latency, looped through the work area). Preview panel Include Audio / playback.muteAudio. - Audio panel VU meters with peak hold and clip indicators; selected layer's Audio Levels. - Timeline Audio > Waveform for footage with audio (L reveals Audio Levels, LL the Waveform), drawn from a cached per-item peak summary. - ui.playback reports audio state and meter levels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1c0da40173
commit
ddb33cdfd0
+202
-50
@@ -1,10 +1,20 @@
|
||||
//! Audio mixdown of a composition: every audible footage layer (and, recursively, precomp layer)
|
||||
//! summed with its Audio Levels (dB, per channel), respecting in/out points, the Audio switch and
|
||||
//! solo. Levels are evaluated once per call (callers mix in short blocks, e.g. one video frame).
|
||||
//! Audio mixdown of a composition: every audible layer (footage with audio, precomps, and any
|
||||
//! layer with a sound-generating effect such as Tone) summed with its Audio Levels (dB, per
|
||||
//! channel), respecting in/out points, the Audio switch and solo. Layer audio effects
|
||||
//! (Effect > Audio, see [`effectcraft_effects::audio_fx`]) run on each layer's audio before
|
||||
//! its levels; Backwards plays the layer's [in, out) span reversed.
|
||||
//!
|
||||
//! Callers mix in short blocks (one video frame, or an audio device buffer). Effects and levels
|
||||
//! are evaluated once per block. To make the result independent of block boundaries, layer
|
||||
//! effects are run from a cleared state over a pre-roll of earlier audio and only the block's
|
||||
//! tail is kept — so preview playback and export produce identical samples.
|
||||
//!
|
||||
//! Time-stretched layers are resampled nearest-sample; reversed (negative stretch) layers are
|
||||
//! silent for now.
|
||||
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project};
|
||||
use effectcraft_effects::Params;
|
||||
use effectcraft_effects::audio_fx;
|
||||
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerSource, Node, Project};
|
||||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||||
|
||||
use crate::{EvalCtx, ExprHost, FootageSource};
|
||||
@@ -19,32 +29,49 @@ pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dy
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the comp has anything audible (footage with audio, or a precomp that has).
|
||||
/// Whether the comp has anything audible (footage with audio, a sound generator, or a precomp
|
||||
/// that has).
|
||||
pub fn comp_has_audio(project: &Project, comp: ItemId) -> bool {
|
||||
fn walk(project: &Project, comp: ItemId, depth: usize) -> bool {
|
||||
let Some(c) = project.comp(comp) else { return false };
|
||||
depth < MAX_DEPTH
|
||||
&& c.layers.iter().any(|l| {
|
||||
l.switches.audio
|
||||
&& match &l.source {
|
||||
LayerSource::Footage { item } => {
|
||||
matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing)
|
||||
}
|
||||
LayerSource::Comp { item } => walk(project, *item, depth + 1),
|
||||
_ => false,
|
||||
}
|
||||
&& (generates(l)
|
||||
|| match &l.source {
|
||||
LayerSource::Footage { item } => {
|
||||
matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio && !f.missing)
|
||||
}
|
||||
LayerSource::Comp { item } => walk(project, *item, depth + 1),
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
walk(project, comp, 0)
|
||||
}
|
||||
|
||||
fn audible(project: &Project, l: &Layer) -> bool {
|
||||
/// Enabled audio effects of a layer (in stack order) with their effect ids.
|
||||
fn audio_effect_groups(l: &Layer) -> impl Iterator<Item = (&str, &effectcraft_project::PropGroup)> {
|
||||
l.effects().filter(|_| l.switches.effects).into_iter().flat_map(|fx| fx.groups()).filter(|g| g.enabled).filter_map(|g| match &g.kind {
|
||||
GroupKind::Effect { effect } if audio_fx::is_audio_effect(effect) => Some((effect.as_str(), g)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The layer makes sound on its own (Tone).
|
||||
fn generates(l: &Layer) -> bool {
|
||||
audio_effect_groups(l).any(|(id, _)| audio_fx::generates_audio(id))
|
||||
}
|
||||
|
||||
/// Does the layer contribute to the mix (Audio switch on and something to hear)?
|
||||
pub fn audible(project: &Project, l: &Layer) -> bool {
|
||||
l.switches.audio
|
||||
&& match &l.source {
|
||||
LayerSource::Footage { item } => matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio),
|
||||
LayerSource::Comp { .. } => true,
|
||||
_ => false,
|
||||
}
|
||||
&& (generates(l)
|
||||
|| match &l.source {
|
||||
LayerSource::Footage { item } => matches!(project.item(*item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_audio),
|
||||
LayerSource::Comp { .. } => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn db_to_gain(db: f64) -> f32 {
|
||||
@@ -61,6 +88,116 @@ fn sample_tick(i: i64, rate: u32) -> Tick {
|
||||
Tick(((i as i128 * TICKS_PER_SECOND as i128) / rate as i128) as i64)
|
||||
}
|
||||
|
||||
struct MixEnv<'a> {
|
||||
project: &'a Project,
|
||||
footage: &'a dyn FootageSource,
|
||||
expr: Option<&'a dyn ExprHost>,
|
||||
comp_id: ItemId,
|
||||
comp: &'a Comp,
|
||||
rate: u32,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl MixEnv<'_> {
|
||||
fn ctx(&self, t: Tick) -> EvalCtx<'_> {
|
||||
let mut ctx = EvalCtx::new(self.project, self.comp_id, self.comp, t);
|
||||
ctx.expr = self.expr;
|
||||
ctx
|
||||
}
|
||||
|
||||
/// The layer's own (pre-effect) audio for comp sample indices `i0 .. i0 + n`, silent
|
||||
/// outside its [in, out) span. Comp sample index `k` maps to `in + out − 1 − k` when
|
||||
/// `reversed`.
|
||||
fn layer_raw(&self, l: &Layer, i0: i64, n: usize, reversed: bool) -> Vec<f32> {
|
||||
let rate = self.rate;
|
||||
let mut out = vec![0.0f32; n * 2];
|
||||
let (in_i, out_i) = (units_ceil(l.in_point, rate), units_ceil(l.out_point, rate));
|
||||
if reversed {
|
||||
// Mirror the clipped range, fetch it forwards, reverse.
|
||||
let a = i0.max(in_i);
|
||||
let b = (i0 + n as i64).min(out_i);
|
||||
if a >= b {
|
||||
return out;
|
||||
}
|
||||
let (ma, mb) = (in_i + out_i - b, in_i + out_i - a);
|
||||
let fwd = self.layer_raw(l, ma, (mb - ma) as usize, false);
|
||||
for k in a..b {
|
||||
let j = (in_i + out_i - 1 - k - ma) as usize;
|
||||
let o = (k - i0) as usize;
|
||||
out[o * 2] = fwd[j * 2];
|
||||
out[o * 2 + 1] = fwd[j * 2 + 1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let a = (in_i - i0).clamp(0, n as i64) as usize;
|
||||
let b = (out_i - i0).clamp(0, n as i64) as usize;
|
||||
if a >= b {
|
||||
return out;
|
||||
}
|
||||
let m = b - a;
|
||||
let speed = 100.0 / l.stretch;
|
||||
let src_n = ((m as f64 * speed).ceil() as usize).max(1);
|
||||
let lt = l.layer_time(sample_tick(i0 + a as i64, rate));
|
||||
let buf = match &l.source {
|
||||
LayerSource::Footage { item } => match self.project.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) if f.has_audio => self.footage.audio(*item, f, lt, src_n, rate).unwrap_or_else(|| vec![0.0; src_n * 2]),
|
||||
_ => return out,
|
||||
},
|
||||
LayerSource::Comp { item } => {
|
||||
if self.project.comp_contains(*item, self.comp_id) {
|
||||
return out;
|
||||
}
|
||||
let mut sub = vec![0.0f32; src_n * 2];
|
||||
mix_into(self.project, self.footage, self.expr, *item, lt, rate, &mut sub, 1.0, 1.0, self.depth + 1);
|
||||
sub
|
||||
}
|
||||
_ => return out,
|
||||
};
|
||||
let same = (speed - 1.0).abs() < 1e-9;
|
||||
for i in 0..m {
|
||||
let j = if same { i } else { ((i as f64 * speed) as usize).min(src_n - 1) };
|
||||
if j * 2 + 1 >= buf.len() {
|
||||
break;
|
||||
}
|
||||
out[(a + i) * 2] = buf[j * 2];
|
||||
out[(a + i) * 2 + 1] = buf[j * 2 + 1];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Effect parameters at comp time `t`.
|
||||
fn params(&self, l: &Layer, g: &effectcraft_project::PropGroup, t: Tick) -> Params {
|
||||
let ctx = self.ctx(t);
|
||||
let mut p = Params::default();
|
||||
for c in &g.children {
|
||||
if let Node::Prop(pr) = c {
|
||||
p.values.insert(pr.match_id.clone(), ctx.value(l, pr));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// The layer's processed audio (effects applied, levels not) for comp sample indices
|
||||
/// `i0 .. i0 + n`.
|
||||
fn layer_audio(&self, l: &Layer, i0: i64, n: usize) -> Vec<f32> {
|
||||
let t = sample_tick(i0, self.rate);
|
||||
let fx: Vec<(&str, Params)> = audio_effect_groups(l).map(|(id, g)| (id, self.params(l, g, t))).collect();
|
||||
if fx.is_empty() {
|
||||
return self.layer_raw(l, i0, n, false);
|
||||
}
|
||||
let reversed = fx.iter().any(|(id, _)| audio_fx::reverses(id));
|
||||
let pre_s: f64 = fx.iter().map(|(id, p)| audio_fx::preroll(id, p)).sum();
|
||||
let pre = (pre_s * self.rate as f64).ceil() as usize;
|
||||
let mut buf = self.layer_raw(l, i0 - pre as i64, n + pre, reversed);
|
||||
let start = l.layer_time(sample_tick(i0 - pre as i64, self.rate)).seconds();
|
||||
for (id, p) in &fx {
|
||||
audio_fx::process(id, p, &mut buf, self.rate, start);
|
||||
}
|
||||
buf.drain(..pre * 2);
|
||||
buf
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mix_into(
|
||||
project: &Project,
|
||||
@@ -78,6 +215,7 @@ fn mix_into(
|
||||
if depth >= MAX_DEPTH || rate == 0 {
|
||||
return;
|
||||
}
|
||||
let env = MixEnv { project, footage, expr, comp_id, comp, rate, depth };
|
||||
let frames = out.len() / 2;
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l));
|
||||
let s0 = start.to_units_floor(rate as i64);
|
||||
@@ -92,50 +230,64 @@ fn mix_into(
|
||||
continue;
|
||||
}
|
||||
let t_a = sample_tick(s0 + a as i64, rate);
|
||||
let (ll, lr) = levels(project, comp_id, comp, l, t_a, expr);
|
||||
let (ll, lr) = levels(&env, l, t_a);
|
||||
let (ll, lr) = (ll * gl, lr * gr);
|
||||
if ll == 0.0 && lr == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let n = b - a;
|
||||
let speed = 100.0 / l.stretch;
|
||||
let src_n = ((n as f64 * speed).ceil() as usize).max(1);
|
||||
let lt = l.layer_time(t_a);
|
||||
let buf = match &l.source {
|
||||
LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate).unwrap_or_else(|| vec![0.0; src_n * 2]),
|
||||
_ => continue,
|
||||
},
|
||||
LayerSource::Comp { item } => {
|
||||
if project.comp_contains(*item, comp_id) {
|
||||
continue;
|
||||
}
|
||||
let mut sub = vec![0.0f32; src_n * 2];
|
||||
mix_into(project, footage, expr, *item, lt, rate, &mut sub, 1.0, 1.0, depth + 1);
|
||||
sub
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let same = (speed - 1.0).abs() < 1e-9;
|
||||
for i in 0..n {
|
||||
let j = if same { i } else { ((i as f64 * speed) as usize).min(src_n - 1) };
|
||||
if j * 2 + 1 >= buf.len() {
|
||||
break;
|
||||
}
|
||||
out[(a + i) * 2] += buf[j * 2] * ll;
|
||||
out[(a + i) * 2 + 1] += buf[j * 2 + 1] * lr;
|
||||
let buf = env.layer_audio(l, s0 + a as i64, b - a);
|
||||
for (i, f) in buf.chunks_exact(2).enumerate() {
|
||||
out[(a + i) * 2] += f[0] * ll;
|
||||
out[(a + i) * 2 + 1] += f[1] * lr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn levels(project: &Project, comp_id: ItemId, comp: &Comp, l: &Layer, t: Tick, expr: Option<&dyn ExprHost>) -> (f32, f32) {
|
||||
fn levels(env: &MixEnv, l: &Layer, t: Tick) -> (f32, f32) {
|
||||
let Some(g) = l.props.sub("audio") else { return (1.0, 1.0) };
|
||||
let mut ctx = EvalCtx::new(project, comp_id, comp, t);
|
||||
ctx.expr = expr;
|
||||
let [a, b] = ctx.v2(l, g, "levels", [0.0, 0.0]);
|
||||
let [a, b] = env.ctx(t).v2(l, g, "levels", [0.0, 0.0]);
|
||||
(db_to_gain(a), db_to_gain(b))
|
||||
}
|
||||
|
||||
/// A min/max peak summary of interleaved stereo audio for waveform drawing: one entry per
|
||||
/// `bin` sample frames, `[min L, max L, min R, max R]`.
|
||||
pub fn peak_summary(samples: &[f32], bin: usize) -> Vec<[f32; 4]> {
|
||||
let bin = bin.max(1);
|
||||
samples
|
||||
.chunks(bin * 2)
|
||||
.map(|c| {
|
||||
let mut s = [f32::MAX, f32::MIN, f32::MAX, f32::MIN];
|
||||
for f in c.chunks_exact(2) {
|
||||
s[0] = s[0].min(f[0]);
|
||||
s[1] = s[1].max(f[0]);
|
||||
s[2] = s[2].min(f[1]);
|
||||
s[3] = s[3].max(f[1]);
|
||||
}
|
||||
if s[0] > s[1] { [0.0; 4] } else { s }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Peak summary of a footage item's audio from source time 0 over `duration`, `bins_per_sec`
|
||||
/// entries per second (rendered at a low internal rate, fetched in one-second chunks).
|
||||
pub fn footage_peaks(footage: &dyn FootageSource, item: ItemId, f: &effectcraft_project::Footage, duration: Tick, bins_per_sec: u32) -> Vec<[f32; 4]> {
|
||||
let bins_per_sec = bins_per_sec.max(1);
|
||||
// Enough samples per bin to catch peaks of audible content, without decoding at full rate.
|
||||
let bin = (64usize).max(4000 / bins_per_sec as usize);
|
||||
let rate = bin as u32 * bins_per_sec;
|
||||
let chunk = bin * bins_per_sec as usize;
|
||||
let total = units_ceil(duration, rate).max(0) as usize;
|
||||
let mut out = Vec::with_capacity(total / bin + 1);
|
||||
let mut i = 0usize;
|
||||
while i < total {
|
||||
let n = chunk.min(total - i);
|
||||
let buf = footage.audio(item, f, sample_tick(i as i64, rate), n, rate).unwrap_or_else(|| vec![0.0; n * 2]);
|
||||
out.extend(peak_summary(&buf, bin));
|
||||
i += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -522,4 +522,6 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
|
||||
effectcraft_path::to_kurbo(sp)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests_audio_fx;
|
||||
#[cfg(test)]
|
||||
mod tests_time;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Audio effects through the mixdown (the path preview playback and export share).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::audio::{comp_has_audio, footage_peaks, mix_comp, peak_summary};
|
||||
use crate::{FootageSource, Image};
|
||||
|
||||
const SR: u32 = 8000;
|
||||
|
||||
/// Synthetic footage audio as a function of source time (seconds) → (L, R).
|
||||
struct Signal(fn(f64) -> (f32, f32));
|
||||
impl FootageSource for Signal {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
|
||||
let t0 = start.seconds();
|
||||
Some(
|
||||
(0..frames)
|
||||
.flat_map(|i| {
|
||||
let t = t0 + i as f64 / rate as f64;
|
||||
let (l, r) = if (0.0..2.0).contains(&t) { (self.0)(t) } else { (0.0, 0.0) };
|
||||
[l, r]
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_footage() -> Footage {
|
||||
Footage {
|
||||
path: "x.wav".into(),
|
||||
kind: FootageKind::Audio,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::new(25, 1),
|
||||
native_rate: None,
|
||||
duration: Tick::from_seconds_f64(2.0),
|
||||
has_video: false,
|
||||
has_audio: true,
|
||||
alpha: Default::default(),
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// A 2 s comp with one audio layer (in 0, out 1 s) carrying effect `id` with `vals`.
|
||||
fn comp_with(id: Option<&str>, vals: &[(&str, Value)], solid: bool) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
|
||||
let src = if solid {
|
||||
let sid = p.add_item("S", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 64, height: 64, pixel_aspect: 1.0 }));
|
||||
LayerSource::Solid { item: sid }
|
||||
} else {
|
||||
LayerSource::Footage { item: p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(audio_footage())) }
|
||||
};
|
||||
let mut l = build::layer(&mut p, &comp, "a", src, (64, 64), None);
|
||||
l.out_point = Tick::from_seconds_f64(1.0);
|
||||
if let Some(id) = id {
|
||||
let spec = effectcraft_effects::find(id).unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 64.0]);
|
||||
p.next_id = next;
|
||||
for (k, v) in vals {
|
||||
g.prop_mut(k).unwrap_or_else(|| panic!("{k}")).value = v.clone();
|
||||
}
|
||||
l.props.sub_mut("effects").unwrap().children.push(g.into());
|
||||
}
|
||||
let mut comp = comp;
|
||||
comp.layers = vec![l];
|
||||
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn mix(p: &Project, src: &dyn FootageSource, cid: ItemId, from: f64, frames: usize) -> Vec<f32> {
|
||||
mix_comp(p, src, None, cid, Tick::from_seconds_f64(from), frames, SR)
|
||||
}
|
||||
|
||||
fn ramp(t: f64) -> (f32, f32) {
|
||||
(t as f32, -(t as f32))
|
||||
}
|
||||
|
||||
fn rms(m: &[f32]) -> f32 {
|
||||
(m.iter().map(|v| v * v).sum::<f32>() / m.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backwards_reverses_the_layer_span() {
|
||||
let (p, cid) = comp_with(Some("ec.audio.backwards"), &[], false);
|
||||
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
|
||||
// Comp time t plays source time ≈ 1 − t.
|
||||
for i in [0usize, 2000, 4000, 7999] {
|
||||
let t = i as f64 / SR as f64;
|
||||
assert!((m[i * 2] as f64 - (1.0 - t)).abs() < 2.0 / SR as f64, "i={i}: {}", m[i * 2]);
|
||||
}
|
||||
let (p, cid) = comp_with(None, &[], false);
|
||||
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
|
||||
assert!((m[4000 * 2] - 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_low_pass_attenuates() {
|
||||
fn hi(t: f64) -> (f32, f32) {
|
||||
let v = (2.0 * std::f64::consts::PI * 3000.0 * t).sin() as f32;
|
||||
(v, v)
|
||||
}
|
||||
let vals = [("filterOptions", Value::Enum(1)), ("cutoffFrequency", Value::Scalar(200.0))];
|
||||
let (p, cid) = comp_with(Some("ec.audio.highlowpass"), &vals, false);
|
||||
let r = rms(&mix(&p, &Signal(hi), cid, 0.5, 800));
|
||||
assert!(r < 0.02, "{r}");
|
||||
let (p, cid) = comp_with(None, &[], false);
|
||||
let r = rms(&mix(&p, &Signal(hi), cid, 0.5, 800));
|
||||
assert!(r > 0.6, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_offsets_across_blocks() {
|
||||
fn click(t: f64) -> (f32, f32) {
|
||||
if (t * SR as f64).round() as i64 == 2000 { (1.0, 1.0) } else { (0.0, 0.0) }
|
||||
}
|
||||
let vals = [("delayTime", Value::Scalar(100.0)), ("feedback", Value::Scalar(0.0))];
|
||||
let (p, cid) = comp_with(Some("ec.audio.delay"), &vals, false);
|
||||
// The block starts after the click (0.25 s): the echo at 0.35 s still comes from pre-roll.
|
||||
let m = mix(&p, &Signal(click), cid, 0.3, 800);
|
||||
let i = 2800 - 2400;
|
||||
assert!((m[i * 2] - 0.375).abs() < 1e-5, "{}", m[i * 2]);
|
||||
assert_eq!(m.iter().filter(|v| v.abs() > 1e-6).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_mixer_pans() {
|
||||
fn left(_: f64) -> (f32, f32) {
|
||||
(0.5, 0.0)
|
||||
}
|
||||
let (p, cid) = comp_with(Some("ec.audio.stereomixer"), &[("leftPan", Value::Scalar(100.0))], false);
|
||||
let m = mix(&p, &Signal(left), cid, 0.2, 10);
|
||||
assert!(m.chunks(2).all(|f| f[0].abs() < 1e-6 && (f[1] - 0.5).abs() < 1e-6), "{:?}", &m[..4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_makes_a_solid_audible_at_its_frequency() {
|
||||
let mut vals: Vec<(&str, Value)> = vec![("frequency1", Value::Scalar(500.0))];
|
||||
for k in ["frequency2", "frequency3", "frequency4", "frequency5"] {
|
||||
vals.push((k, Value::Scalar(0.0)));
|
||||
}
|
||||
let (p, cid) = comp_with(Some("ec.audio.tone"), &vals, true);
|
||||
assert!(comp_has_audio(&p, cid));
|
||||
let m = mix(&p, &Signal(ramp), cid, 0.0, SR as usize);
|
||||
let left: Vec<f32> = m.iter().step_by(2).copied().collect();
|
||||
let ups = left.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
|
||||
assert!((ups as i64 - 500).abs() <= 1, "{ups}");
|
||||
// The out point (1 s) silences it.
|
||||
let after = mix(&p, &Signal(ramp), cid, 1.0, 100);
|
||||
assert!(after.iter().all(|v| *v == 0.0));
|
||||
let (p, cid) = comp_with(None, &[], true);
|
||||
assert!(!comp_has_audio(&p, cid));
|
||||
}
|
||||
|
||||
/// Mixing in video-frame blocks gives the same samples as one long block (pre-roll makes
|
||||
/// effects stateless across calls), so preview and export agree.
|
||||
#[test]
|
||||
fn blocks_match_one_long_mix() {
|
||||
fn noise(t: f64) -> (f32, f32) {
|
||||
let x = ((t * 12345.678).sin() * 43758.5453).fract() as f32;
|
||||
(x, -x)
|
||||
}
|
||||
for id in ["ec.audio.reverb", "ec.audio.basstreble", "ec.audio.flangechorus", "ec.audio.modulator", "ec.audio.delay", "ec.audio.parametriceq"] {
|
||||
let vals: Vec<(&str, Value)> = match id {
|
||||
"ec.audio.basstreble" => vec![("bass", Value::Scalar(50.0))],
|
||||
"ec.audio.parametriceq" => vec![("band1BoostCut", Value::Scalar(9.0))],
|
||||
_ => vec![],
|
||||
};
|
||||
let (p, cid) = comp_with(Some(id), &vals, false);
|
||||
let long = mix(&p, &Signal(noise), cid, 0.2, 1600);
|
||||
let mut blocks = vec![];
|
||||
for k in 0..5 {
|
||||
blocks.extend(mix(&p, &Signal(noise), cid, 0.2 + k as f64 * 0.04, 320));
|
||||
}
|
||||
let worst = long.iter().zip(&blocks).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst < 2e-3, "{id}: {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_summary_bins() {
|
||||
let s: Vec<f32> = (0..10).flat_map(|i| [i as f32 * 0.1, -(i as f32) * 0.1]).collect();
|
||||
let p = peak_summary(&s, 4);
|
||||
assert_eq!(p.len(), 3);
|
||||
assert!(p[0][0].abs() < 1e-6 && (p[0][1] - 0.3).abs() < 1e-6);
|
||||
assert!((p[1][2] + 0.7).abs() < 1e-6 && (p[1][3] + 0.4).abs() < 1e-6);
|
||||
let f = audio_footage();
|
||||
let peaks = footage_peaks(&Signal(ramp), ItemId(1), &f, Tick::from_seconds_f64(2.0), 100);
|
||||
assert_eq!(peaks.len(), 200);
|
||||
assert!((peaks[150][1] - 1.51).abs() < 0.01, "{:?}", peaks[150]);
|
||||
}
|
||||
Reference in New Issue
Block a user