mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 04:47:14 +00:00
* frames: keep cached frames an edit can't reach Nudging, trimming or splitting a layer re-rendered the whole comp. The RAM preview now carries every frame whose stack of active layers is unchanged to the new comp identity; only the segments an edit touches re-render. * disk cache: never drop frame writes; save frames kept after edits Frames get their own write lane (bounded by bytes, written before layer buffers), so a burst of layer writes no longer drops frames and leaves gaps in the blue bar after reopening. Frames an edit kept in RAM are written to disk under the comp's new content key when idle. * frames: music and hidden layers keep the caches Layers that can't change pixels (audio-only footage, AV layers with Video off, when nothing reads them and there are no expressions) no longer count for the RAM preview's dirty segments or the disk cache's comp and footage keys. Adding, trimming or moving music keeps both caches. RENDER_VERSION bumps to effectcraft-render-3 for the new key formula. * ui-egui: background caching benchmark cache_bench fills a comp's RAM preview the way idle caching does and prints frames/s with decoder, footage-cache and disk-cache counters. * frames: soloed music and hidden layers aren't silent Solo hides every other layer, so soloing a music track or a layer with Video off changes the picture. silent_layers no longer drops soloed layers, so the RAM preview and the disk cache see Solo toggles. * disk cache: audio files a layer reads stay in the layer salt Audio-only footage left the disk layer salt so importing music keeps the keys, but Audio Spectrum and Audio Waveform draw from an audio layer. Footage another layer reads (or any, when a read layer is a precomp) counts again, so overwriting that file doesn't reuse stale buffers. * disk cache: retry frame writes the queue refused put_frame reports whether the frame is on disk or queued. persist_missing forgets the key of a refused write, so a full frame lane no longer leaves RAM frames that are never written. * ui-egui: cache_bench measures idle caching; checks --comp and --slots Frames go through request_background with one slot per frame thread, as Cache Frames When Idle does. An unknown --comp is an error (the active comp is used only without --comp), --slots is at least 1, and the disk report flushes queued writes instead of sleeping. * disk cache: RENDER_VERSION effectcraft-render-6 This branch changes the cache-key formula (silent layers, Solo, read audio in the footage salt), and upstream #2759 already took render-5 for the clip-clock change, so entries from either must not be reused. * frames: rewrite RAM frames the disk cache evicted persist_missing kept every disk key it ever queued, so a frame the disk cache evicted later was never written back. The disk cache now tracks frame writes still queued or in progress (frame_pending), and persist_missing only remembers keys while it converts them. * frames: idle passes continue while RAM frames wait for disk persist_missing reports whether there may be more to write (a content key being computed, frames being converted). Cache Frames When Idle then schedules another pass, so a refused write is retried without waiting for a UI event, and the passes stop once every frame is on disk or queued. * frames: rewrite an evicted frame once, not on every idle pass A disk cache smaller than the RAM preview, or a frame too large for it, made idle passes write, evict and rewrite the same frames every 500 ms. Each disk key is now written at most twice under one disk limit (the write and one rewrite after an eviction); a new limit resets the count. The persist test waits for the conversion to forget its keys before checking that no work is left. * frames: the work area doesn't change pixels Setting the work area (B, N) cleared every cached frame, in RAM and on disk: it was compared with the comp's pixel settings and hashed into the disk key. It is a preview and render range only, like the Resolution, so it no longer counts. * frames: audio edits keep frames unless something draws the audio Changing a clip's Audio Levels, its audio effects or its Audio switch cleared the clip's cached frames, and the comp's disk cache, although nothing drew that audio. The audio now counts only for the layers whose audio is heard (Project::heard_layers): those a layer picks in an effect (an Audio Waveform or Spectrum, even on itself), the layers of a picked precomp, and every layer while an expression (which can read Audio Levels) exists. Music in a precomp that a waveform in the outer comp picks is no longer treated as silent either. * frames: an expression only reaches the frames of its own layer One expression anywhere in the project made every edit clear the comp's whole cache, and so did editing a layer another layer reads (its parent, matte, or a layer it picks). Expressions can read anything, but they only draw in their own layer: an edit now re-renders the frames where it changed something, plus those of the layers with an expression and of the layers that read a reached layer. With expressions, an edit elsewhere (another comp, an item's name, a marker, music) reaches only the expression layers' frames. * frames: an edit inside a precomp re-renders only where the precomp shows it Editing anything inside a precomp cleared every cached frame of the comps that nest it, even a music level in it. Project::dirty_spans now follows precomps: it takes the spans their comp changed in, shifts and scales them by the precomp layer's start and stretch (its whole span under a time remap or Responsive Design timing), and keeps the outer comp's other frames. Layers that read a changed precomp (a matte, a waveform) re-render where they are active. * disk cache: keep the frames an edit can't reach The disk cache keys frames on a hash of the whole comp, so every edit lost the frames that were only on disk (the blue bar), even a rename; only those still in RAM were written again under the new key. When an edit keeps RAM frames, it now also queues a disk carry: on the frame pool, each kept frame of every disk series in use for that comp is hard linked (or copied) to its new key with DiskCache::link_frame, oldest edit first. The old entries stay, so undo finds them too. * frames: a keyframe edit re-renders only around the keys it changed Changing one keyframe (its value, interpolation or ease, in the Timeline or the Graph Editor) re-rendered the layer's whole length, so a key on a full-length layer cleared the whole cache. A layer that differs only in keyframes of its Transform properties now re-renders from two keys before the first changed key to two after the last (auto-Bezier and continuous tangents reach the neighbouring segments), to its in or out point past the first or last key. Static values, expressions, roving keys, time remap and time effects still reach the whole layer; effect keyframes do too for now (simulations and time effects read other frames). * frames: music volume keeps frames while no expression reads audio Any expression in the project (a wiggle on another layer, even a disabled one) made every layer's audio count as read, so changing a music layer's Audio Levels re-rendered every expression layer and changed the comp's disk key. Audio now counts for expressions only when one may read it (Heard::audio_exprs): its text names audio or walks properties by index. An audio-only change nothing reads no longer counts as a change elsewhere in dirty_spans either. * frames: a keyframe edit with few keys or beside a self-contained expression keeps the other frames Changing a Scale key of a layer with two or three keys re-rendered the layer's whole length: with fewer than two keys before (or after) the changed one, the span fell back to the in (or out) point instead of the first (or last) key, past which the value holds. And any layer with an expression re-rendered on every edit, so a wiggle on a full-length layer cleared the whole cache. An expression layer is now reached by edits elsewhere only when its text may read outside its layer (it names a comp, layer, footage, the project or its source); its parent and the layers it picks are still read through referenced_layers. * viewer: the caching chip stays up between uncached frames, with a spinner The "Caching frames…" chip showed only while playback waited for a frame, so during caching it flickered on and off with every frame that arrived. It now stays up 0.6 s after the last wait (and hides at once when playback stops). It is also smaller and quieter: a spinner, then "Caching frames" without the ellipsis in dimmed smaller text, on a fainter chip sized to its text. * preview: the Preview panel's cache bar counts the preview's Resolution With a preview Resolution set (Half, Third…), the frames a preview plays are cached at that scale, but the Preview panel's bar counted the series the viewer shows. When playback stopped, the viewer went back to the comp's Resolution and the bar dropped from full to the frames cached there. It now counts the preview's own series (Auto still follows the viewer). The viewer and the Timeline's cache bar are unchanged. * Contributors: add nazar-gavrylyk's name * frames: an expression reading its own layer's markers or audio re-renders that layer With expressions in the project, a layer's markers count when comparing it, and its audio counts when an expression may read audio, so a marker or volume edit re-renders the edited layer over its own span. Comp markers still count only through layers whose expressions name thisComp. * frames: a keyframe edit on a layer with an expression re-renders the layer An expression on the keyed layer can read the keyed property at other times (Rotation reading Position with valueAtTime), so the frames past the changed keys may change too. * cache_bench: read decoder counters through effectcraft-media Only crates/media, crates/export and crates/interchange may depend on the FilmCraft crates. * cache_bench: clips end where the next one starts * frames: write-back uses the worker's disk keys while a Roto Brush model is active persist_missing keyed disk frames on FrameKey::opts, which also carries the Roto Brush model stamp; the worker and disk_frames key on the render opts' hash alone. With a model active every RAM frame looked missing, was written again under keys nobody reads, and recorded a second disk series. * frames: an edit two precomps down reaches the outer comp A precomp layer whose comp Arc didn't change was skipped, but the comps nested in it may have (an edit in Inner leaves Mid's Arc as it was), so Outer kept every frame. Every nested comp is walked now, each once per edit. * cache_bench: stop when the disk cache can't be cleared * frames: one disk carry job at a time carry_disk runs on every timeline paint and idle pass, and spawned a pool job whenever carries were queued; during a drag the extra jobs waited on the carry lock and held frame-pool threads from the viewer. A scheduled flag keeps one job, which drains the queue in order until it's empty. * frames: a panicking disk carry is skipped, not left holding the job The carry job clears carry_scheduled only on its normal exit; a panic in a carry would have left it set for the session, so no later carry ran. Each carry is caught like the content key's hashing, so the job always reaches its exit. * frames: an expression reading its own in point or index re-renders its layer A trim or rename kept the layer's frames outside the cut when the layer drew the same apart from its span, but an expression on it can read its own inPoint, outPoint or name (a fade from inPoint), so every frame of the layer changes. Such a layer is no longer treated as only respanned. An expression reading `index` reads the layers above it too: a layer added or removed there shifts it, so `index` now counts as reading outside the layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * frames: an edit of a comp reaches the expressions of the precomps it nests An expression in a precomp can read the comp that nests it (`comp("Main").layer("Controls")`), but the walk into nested comps only counted changes outside the edited top comp. Editing a hidden Controls layer there (or any layer, when the precomp is time-shifted or reads it at other times) kept every precomp frame in RAM and linked them all on disk under the new key. Nested comps now see an edit of the comp above them as an edit elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * frames: an edit in a precomp under a time effect reaches its whole layer A precomp layer running Echo, Posterize Time, Time Displacement or another effect that reads the layer at other times shows a nested change far from where it maps, but only the mapped span (a nested frame either side) re-rendered. Such a layer now gives its whole span, like a time remap. `reads_other_frames` lists those effects (every `ec.time.` and `ec.sim.` effect and the others that call `self_at`); keyframe spans use it too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * frames: a keyframe edit beside muted keys re-renders the whole layer Muted keys are skipped when a property is evaluated, so the keys that play around an edited key can be any distance away, but the span counted two stored keys either side. A segment from an earlier playing key kept its stale frames. Muted keys now give the layer's whole span, like roving keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * frames: one echo in the precomp time-effect test (three saturate alike), rustfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1030 lines
44 KiB
Rust
1030 lines
44 KiB
Rust
//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Compressor, Delay, Distortion,
|
||
//! Flange & Chorus, Gate, High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer and Tone.
|
||
//!
|
||
//! They are ordinary effect instances on a layer (so their parameters animate and show in
|
||
//! Effect Controls), pass video through unchanged, and are applied to the layer's audio by the
|
||
//! composition mixdown (`effectcraft_render::audio`), which preview playback and export share.
|
||
//!
|
||
//! The mixdown renders in short blocks. To keep the result independent of block boundaries
|
||
//! every effect is stateless across calls: the mixer feeds [`preroll`] seconds of the layer's
|
||
//! audio before the block, runs the chain from a cleared state and keeps the tail.
|
||
//!
|
||
//! DSP is textbook: second-order sections use the public RBJ "Audio EQ Cookbook" formulas;
|
||
//! Reverb is a Schroeder/Moorer network (parallel low-pass feedback combs into series
|
||
//! all-passes, as in the published Freeverb description); chorus/flange/vibrato are modulated
|
||
//! fractional delay lines. Compressor and Gate are feed-forward dynamics processors on a
|
||
//! stereo-linked peak envelope (one-pole attack/release smoothing, the textbook quadratic soft-knee
|
||
//! gain computer, an optional program-dependent "auto" release and an output ceiling); Gate mutes
|
||
//! below its threshold with attack / hold / release; Distortion is a static waveshaper (soft and
|
||
//! hard clip, two saturation curves, tube, fuzz) with pre-gain, bit reduction, sample-and-hold
|
||
//! downsampling, dry/wet mix and output volume.
|
||
|
||
use std::f64::consts::PI;
|
||
|
||
use effectcraft_keyframe::Value;
|
||
use effectcraft_project::ParamUi;
|
||
|
||
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
|
||
|
||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>) -> EffectSpec {
|
||
EffectSpec { id, name, category: "Audio", params, render: passthrough, gpu: false, float: true }
|
||
}
|
||
|
||
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
|
||
b
|
||
}
|
||
|
||
fn pct(default: f64) -> (Value, ParamUi) {
|
||
(num(default), slider(0.0, 100.0, 0.0, 100.0, 2))
|
||
}
|
||
|
||
fn pp(id: &'static str, name: &'static str, v: (Value, ParamUi)) -> crate::ParamSpec {
|
||
p(id, name, v.0, v.1)
|
||
}
|
||
|
||
/// Distortion's Type options.
|
||
pub const DISTORTION_TYPES: [&str; 6] = ["Soft Clip", "Hard Clip", "Saturation 1", "Saturation 2", "Tube", "Fuzz"];
|
||
|
||
pub fn specs() -> Vec<EffectSpec> {
|
||
const BANDS: [[&str; 8]; 3] = [
|
||
["band1Enable", "Band 1 Enabled", "band1Frequency", "Band 1 Frequency", "band1Bandwidth", "Band 1 Bandwidth", "band1BoostCut", "Band 1 Boost/Cut"],
|
||
["band2Enable", "Band 2 Enabled", "band2Frequency", "Band 2 Frequency", "band2Bandwidth", "Band 2 Bandwidth", "band2BoostCut", "Band 2 Boost/Cut"],
|
||
["band3Enable", "Band 3 Enabled", "band3Frequency", "Band 3 Frequency", "band3Bandwidth", "Band 3 Bandwidth", "band3BoostCut", "Band 3 Boost/Cut"],
|
||
];
|
||
let mut eq = vec![];
|
||
for (b, (f, on)) in BANDS.iter().zip([(200.0, true), (1000.0, false), (5000.0, false)]) {
|
||
eq.push(p(b[0], b[1], Value::Bool(on), ParamUi::Checkbox));
|
||
eq.push(p(b[2], b[3], num(f), slider(20.0, 20000.0, 20.0, 20000.0, 1)));
|
||
eq.push(p(b[4], b[5], num(30.0), slider(1.0, 100.0, 1.0, 100.0, 1)));
|
||
eq.push(p(b[6], b[7], num(0.0), slider(-24.0, 24.0, -24.0, 24.0, 2)));
|
||
}
|
||
vec![
|
||
spec("ec.audio.backwards", "Backwards", vec![p("swapChannels", "Swap Channels", Value::Bool(false), ParamUi::Checkbox)]),
|
||
spec(
|
||
"ec.audio.basstreble",
|
||
"Bass & Treble",
|
||
vec![
|
||
p("bass", "Bass", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||
p("treble", "Treble", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.compressor",
|
||
"Compressor",
|
||
vec![
|
||
p("threshold", "Threshold (dB)", num(-16.0), slider(-60.0, 0.0, -60.0, 0.0, 2)),
|
||
p("ratio", "Ratio (x:1)", num(3.0), slider(0.4, 30.0, 0.4, 30.0, 2)),
|
||
p("knee", "Knee (dB)", num(15.0), slider(0.0, 30.0, 0.0, 30.0, 2)),
|
||
p("attack", "Attack (ms)", num(6.0), slider(0.0, 400.0, 0.0, 400.0, 2)),
|
||
p("release", "Release (ms)", num(440.0), slider(1.0, 4000.0, 1.0, 4000.0, 2)),
|
||
p("autoRelease", "Auto Release", Value::Bool(true), ParamUi::Checkbox),
|
||
p("makeupGain", "Makeup Gain (dB)", num(0.0), slider(-30.0, 30.0, -30.0, 30.0, 2)),
|
||
p("outputLimit", "Output Limit (dB)", num(0.0), slider(-30.0, 0.0, -30.0, 0.0, 2)),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.delay",
|
||
"Delay",
|
||
vec![
|
||
p("delayTime", "Delay Time (milliseconds)", num(500.0), slider(1.0, 10000.0, 1.0, 2000.0, 2)),
|
||
pp("delayAmount", "Delay Amount", pct(50.0)),
|
||
pp("feedback", "Feedback", pct(50.0)),
|
||
pp("dryOut", "Dry Out", pct(75.0)),
|
||
pp("wetOut", "Wet Out", pct(75.0)),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.distortion",
|
||
"Distortion",
|
||
vec![
|
||
p("distortionType", "Type", Value::Enum(0), popup(&DISTORTION_TYPES)),
|
||
pp("drive", "Drive", pct(25.0)),
|
||
p("gain", "Gain", num(25.0), slider(0.0, 300.0, 0.0, 300.0, 2)),
|
||
pp("mix", "Mix", pct(100.0)),
|
||
p("volume", "Volume", num(11.0), slider(0.0, 100.0, 0.0, 100.0, 2)),
|
||
p("bitDepth", "Resolution (bit)", num(24.0), slider(1.0, 24.0, 1.0, 24.0, 0)),
|
||
p("downsample", "Downsample (x)", num(1.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.flangechorus",
|
||
"Flange & Chorus",
|
||
vec![
|
||
p("voiceSeparationTime", "Voice Separation Time (ms)", num(3.0), slider(0.0, 100.0, 0.0, 50.0, 2)),
|
||
p("voices", "Voices", num(1.0), slider(1.0, 8.0, 1.0, 8.0, 0)),
|
||
p("modulationRate", "Modulation Rate", num(0.4), slider(0.0, 10.0, 0.0, 5.0, 2)),
|
||
pp("modulationDepth", "Modulation Depth", pct(50.0)),
|
||
p("voicePhaseChange", "Voice Phase Change", num(0.0), ParamUi::Angle),
|
||
p("invertPhase", "Invert Phase", Value::Bool(false), ParamUi::Checkbox),
|
||
p("stereoVoices", "Stereo Voices", Value::Bool(false), ParamUi::Checkbox),
|
||
pp("dryOut", "Dry Out", pct(50.0)),
|
||
pp("wetOut", "Wet Out", pct(50.0)),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.gate",
|
||
"Gate",
|
||
vec![
|
||
p("threshold", "Threshold (dB)", num(-60.0), slider(-60.0, 0.0, -60.0, 0.0, 2)),
|
||
p("attack", "Attack (ms)", num(10.0), slider(0.0, 1000.0, 0.0, 1000.0, 2)),
|
||
p("hold", "Hold (ms)", num(40.0), slider(0.0, 2500.0, 0.0, 2500.0, 2)),
|
||
p("release", "Release (ms)", num(220.0), slider(5.0, 4000.0, 5.0, 4000.0, 2)),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.highlowpass",
|
||
"High-Low Pass",
|
||
vec![
|
||
p("filterOptions", "Filter Options", Value::Enum(0), popup(&["High Pass", "Low Pass"])),
|
||
p("cutoffFrequency", "Cutoff Frequency", num(2000.0), slider(20.0, 20000.0, 20.0, 20000.0, 1)),
|
||
pp("dryOut", "Dry Out", pct(0.0)),
|
||
pp("wetOut", "Wet Out", pct(100.0)),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.modulator",
|
||
"Modulator",
|
||
vec![
|
||
p("modulationType", "Modulation Type", Value::Enum(0), popup(&["Sine", "Triangle"])),
|
||
p("modulationRate", "Modulation Rate", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
|
||
pp("modulationDepth", "Modulation Depth", pct(5.0)),
|
||
pp("amplitudeModulation", "Amplitude Modulation", pct(0.0)),
|
||
],
|
||
),
|
||
spec("ec.audio.parametriceq", "Parametric EQ", eq),
|
||
spec(
|
||
"ec.audio.reverb",
|
||
"Reverb",
|
||
vec![
|
||
p("reverbTime", "Reverb Time (milliseconds)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)),
|
||
pp("diffusion", "Diffusion", pct(75.0)),
|
||
pp("decay", "Decay", pct(25.0)),
|
||
pp("brightness", "Brightness", pct(10.0)),
|
||
pp("dryOut", "Dry Out", pct(90.0)),
|
||
pp("wetOut", "Wet Out", pct(10.0)),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.stereomixer",
|
||
"Stereo Mixer",
|
||
vec![
|
||
p("leftLevel", "Left Level", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 2)),
|
||
p("rightLevel", "Right Level", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 2)),
|
||
p("leftPan", "Left Pan", num(-100.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||
p("rightPan", "Right Pan", num(100.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
|
||
p("invertPhase", "Invert Phase", Value::Bool(false), ParamUi::Checkbox),
|
||
],
|
||
),
|
||
spec(
|
||
"ec.audio.tone",
|
||
"Tone",
|
||
vec![
|
||
p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Sine", "Triangle", "Saw", "Square", "White Noise"])),
|
||
p("frequency1", "Frequency 1", num(440.0), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||
p("frequency2", "Frequency 2", num(493.88), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||
p("frequency3", "Frequency 3", num(587.33), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||
p("frequency4", "Frequency 4", num(659.26), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||
p("frequency5", "Frequency 5", num(783.99), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
|
||
pp("level", "Level", pct(20.0)),
|
||
],
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Is `id` an audio effect (applied by the mixdown rather than the compositor)?
|
||
pub fn is_audio_effect(id: &str) -> bool {
|
||
effectcraft_project::is_audio_effect(id)
|
||
}
|
||
|
||
/// Does the effect produce sound on its own (so a layer without audio becomes audible)?
|
||
pub fn generates_audio(id: &str) -> bool {
|
||
id == "ec.audio.tone"
|
||
}
|
||
|
||
/// Does the effect play the layer's audio backwards (a time mapping the mixer applies)?
|
||
pub fn reverses(id: &str) -> bool {
|
||
id == "ec.audio.backwards"
|
||
}
|
||
|
||
/// Does each output sample depend only on the input sample at the same time (no state), so the
|
||
/// mixer may run the effect in short pieces with parameters animated between them?
|
||
pub fn per_sample(id: &str) -> bool {
|
||
matches!(id, "ec.audio.stereomixer" | "ec.audio.backwards")
|
||
}
|
||
|
||
/// Seconds of input history the effect needs before a block to reach the state it would have
|
||
/// had when processing continuously.
|
||
pub fn preroll(id: &str, params: &Params) -> f64 {
|
||
match id {
|
||
"ec.audio.basstreble" | "ec.audio.highlowpass" | "ec.audio.parametriceq" => 0.05,
|
||
"ec.audio.flangechorus" | "ec.audio.modulator" => 0.06,
|
||
"ec.audio.distortion" => 0.01,
|
||
// The envelope settles within a few attack/release time constants.
|
||
"ec.audio.compressor" | "ec.audio.gate" => {
|
||
let auto = id == "ec.audio.compressor" && params.b("autoRelease");
|
||
let rel = if auto { AUTO_RELEASE_SLOW_MS } else { params.f("release").max(1.0) } / 1000.0;
|
||
let att = params.f("attack").max(0.0) / 1000.0;
|
||
let hold = if id == "ec.audio.gate" { params.f("hold").max(0.0) / 1000.0 } else { 0.0 };
|
||
(9.0 * rel + 9.0 * att + hold + 0.01).min(10.0)
|
||
}
|
||
"ec.audio.delay" => {
|
||
let d = params.f("delayTime").max(1.0) / 1000.0;
|
||
let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99);
|
||
let taps = if fb > 1e-3 { (1e-3f64.ln() / fb.ln()).ceil() + 1.0 } else { 1.0 };
|
||
(d * taps).min(10.0)
|
||
}
|
||
"ec.audio.reverb" => {
|
||
let (fb, _, _, pre) = reverb_params(params);
|
||
// T60 of the longest comb (~37 ms) with loop gain fb, plus the pre-delay.
|
||
let t60 = if fb > 1e-3 { -3.0 * 0.0367 / fb.log10() } else { 0.05 };
|
||
(t60 + pre).min(4.0)
|
||
}
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
/// Run audio effect `id` over interleaved stereo `buf` (L R L R …) at `rate` Hz, from a cleared
|
||
/// state. `start` is the layer time (seconds) of the first sample (oscillator phase).
|
||
pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64) {
|
||
if rate == 0 || buf.len() < 2 {
|
||
return;
|
||
}
|
||
let sr = rate as f64;
|
||
match id {
|
||
"ec.audio.backwards" if params.b("swapChannels") => {
|
||
buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1));
|
||
}
|
||
"ec.audio.basstreble" => {
|
||
let bass = params.f("bass") / 100.0 * 12.0;
|
||
let treble = params.f("treble") / 100.0 * 12.0;
|
||
if bass != 0.0 {
|
||
run_biquad(buf, Biquad::low_shelf(sr, 200.0, bass));
|
||
}
|
||
if treble != 0.0 {
|
||
run_biquad(buf, Biquad::high_shelf(sr, 4000.0, treble));
|
||
}
|
||
}
|
||
"ec.audio.highlowpass" => {
|
||
let fc = params.f("cutoffFrequency").clamp(1.0, sr * 0.49);
|
||
let q = std::f64::consts::FRAC_1_SQRT_2;
|
||
let bq = if params.e("filterOptions") == 1 { Biquad::low_pass(sr, fc, q) } else { Biquad::high_pass(sr, fc, q) };
|
||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||
let orig = buf.to_vec();
|
||
run_biquad(buf, bq);
|
||
mix_dry_wet(buf, &orig, dry, wet);
|
||
}
|
||
"ec.audio.parametriceq" => {
|
||
for n in 1..=3 {
|
||
if !params.b(&format!("band{n}Enable")) {
|
||
continue;
|
||
}
|
||
let g = params.f(&format!("band{n}BoostCut"));
|
||
if g == 0.0 {
|
||
continue;
|
||
}
|
||
let f = params.f(&format!("band{n}Frequency")).clamp(1.0, sr * 0.49);
|
||
let bw = (params.f(&format!("band{n}Bandwidth")) / 100.0 * 3.0).max(0.05);
|
||
run_biquad(buf, Biquad::peaking(sr, f, bw, g));
|
||
}
|
||
}
|
||
"ec.audio.delay" => delay(params, buf, sr),
|
||
"ec.audio.compressor" => compressor(params, buf, sr),
|
||
"ec.audio.distortion" => distortion(params, buf, sr, start),
|
||
"ec.audio.gate" => gate(params, buf, sr),
|
||
"ec.audio.flangechorus" => flange_chorus(params, buf, sr, start),
|
||
"ec.audio.modulator" => modulator(params, buf, sr, start),
|
||
"ec.audio.reverb" => reverb(params, buf, sr),
|
||
"ec.audio.stereomixer" => stereo_mixer(params, buf),
|
||
"ec.audio.tone" => tone(params, buf, sr, start),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn mix_dry_wet(buf: &mut [f32], dry_src: &[f32], dry: f32, wet: f32) {
|
||
for (o, d) in buf.iter_mut().zip(dry_src) {
|
||
*o = *o * wet + *d * dry;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- biquads (RBJ cookbook)
|
||
|
||
/// Normalised second-order section coefficients (a0 = 1).
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Biquad {
|
||
pub b: [f64; 3],
|
||
pub a: [f64; 2],
|
||
}
|
||
|
||
impl Biquad {
|
||
fn norm(b0: f64, b1: f64, b2: f64, a0: f64, a1: f64, a2: f64) -> Biquad {
|
||
Biquad { b: [b0 / a0, b1 / a0, b2 / a0], a: [a1 / a0, a2 / a0] }
|
||
}
|
||
pub fn low_pass(sr: f64, f0: f64, q: f64) -> Biquad {
|
||
let w = 2.0 * PI * f0 / sr;
|
||
let (s, c) = w.sin_cos();
|
||
let al = s / (2.0 * q);
|
||
Self::norm((1.0 - c) / 2.0, 1.0 - c, (1.0 - c) / 2.0, 1.0 + al, -2.0 * c, 1.0 - al)
|
||
}
|
||
pub fn high_pass(sr: f64, f0: f64, q: f64) -> Biquad {
|
||
let w = 2.0 * PI * f0 / sr;
|
||
let (s, c) = w.sin_cos();
|
||
let al = s / (2.0 * q);
|
||
Self::norm((1.0 + c) / 2.0, -(1.0 + c), (1.0 + c) / 2.0, 1.0 + al, -2.0 * c, 1.0 - al)
|
||
}
|
||
/// Peaking EQ with bandwidth `bw` in octaves and gain in dB.
|
||
pub fn peaking(sr: f64, f0: f64, bw: f64, db: f64) -> Biquad {
|
||
let a = 10f64.powf(db / 40.0);
|
||
let w = 2.0 * PI * f0 / sr;
|
||
let (s, c) = w.sin_cos();
|
||
let al = s * (2f64.ln() / 2.0 * bw * w / s).sinh();
|
||
Self::norm(1.0 + al * a, -2.0 * c, 1.0 - al * a, 1.0 + al / a, -2.0 * c, 1.0 - al / a)
|
||
}
|
||
/// Shelf with slope S = 1.
|
||
pub fn low_shelf(sr: f64, f0: f64, db: f64) -> Biquad {
|
||
let a = 10f64.powf(db / 40.0);
|
||
let w = 2.0 * PI * f0 / sr;
|
||
let (s, c) = w.sin_cos();
|
||
let al = s / 2.0 * std::f64::consts::SQRT_2;
|
||
let sq = 2.0 * a.sqrt() * al;
|
||
Self::norm(
|
||
a * ((a + 1.0) - (a - 1.0) * c + sq),
|
||
2.0 * a * ((a - 1.0) - (a + 1.0) * c),
|
||
a * ((a + 1.0) - (a - 1.0) * c - sq),
|
||
(a + 1.0) + (a - 1.0) * c + sq,
|
||
-2.0 * ((a - 1.0) + (a + 1.0) * c),
|
||
(a + 1.0) + (a - 1.0) * c - sq,
|
||
)
|
||
}
|
||
pub fn high_shelf(sr: f64, f0: f64, db: f64) -> Biquad {
|
||
let a = 10f64.powf(db / 40.0);
|
||
let w = 2.0 * PI * f0 / sr;
|
||
let (s, c) = w.sin_cos();
|
||
let al = s / 2.0 * std::f64::consts::SQRT_2;
|
||
let sq = 2.0 * a.sqrt() * al;
|
||
Self::norm(
|
||
a * ((a + 1.0) + (a - 1.0) * c + sq),
|
||
-2.0 * a * ((a - 1.0) + (a + 1.0) * c),
|
||
a * ((a + 1.0) + (a - 1.0) * c - sq),
|
||
(a + 1.0) - (a - 1.0) * c + sq,
|
||
2.0 * ((a - 1.0) - (a + 1.0) * c),
|
||
(a + 1.0) - (a - 1.0) * c - sq,
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Direct form I over both channels of an interleaved buffer.
|
||
fn run_biquad(buf: &mut [f32], q: Biquad) {
|
||
for ch in 0..2 {
|
||
let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0);
|
||
for i in (ch..buf.len()).step_by(2) {
|
||
let x = buf[i] as f64;
|
||
let y = q.b[0] * x + q.b[1] * x1 + q.b[2] * x2 - q.a[0] * y1 - q.a[1] * y2;
|
||
x2 = x1;
|
||
x1 = x;
|
||
y2 = y1;
|
||
y1 = y;
|
||
buf[i] = y as f32;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- delay lines
|
||
|
||
/// Linear-interpolated read `d` samples (fractional) behind position `i` of channel `ch`.
|
||
fn read_frac(src: &[f32], i: usize, ch: usize, d: f64) -> f32 {
|
||
let pos = i as f64 - d;
|
||
if pos < 0.0 {
|
||
return 0.0;
|
||
}
|
||
let p0 = pos.floor() as usize;
|
||
let f = (pos - p0 as f64) as f32;
|
||
let a = src[p0 * 2 + ch];
|
||
let b = if p0 < i { src[(p0 + 1) * 2 + ch] } else { a };
|
||
a + (b - a) * f
|
||
}
|
||
|
||
fn delay(params: &Params, buf: &mut [f32], sr: f64) {
|
||
let d = ((params.f("delayTime").max(0.0) / 1000.0 * sr).round() as usize).max(1);
|
||
let amount = (params.f("delayAmount") / 100.0) as f32;
|
||
let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99) as f32;
|
||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||
let n = buf.len() / 2;
|
||
// Echo line: e[i] = x[i-d] + fb * e[i-d]; out = dry*x + wet*amount*e.
|
||
let mut e = vec![0.0f32; buf.len()];
|
||
for i in d..n {
|
||
for ch in 0..2 {
|
||
e[i * 2 + ch] = buf[(i - d) * 2 + ch] + fb * e[(i - d) * 2 + ch];
|
||
}
|
||
}
|
||
for (o, v) in buf.iter_mut().zip(&e) {
|
||
*o = *o * dry + v * wet * amount;
|
||
}
|
||
}
|
||
|
||
#[inline]
|
||
fn db_to_lin(db: f64) -> f64 {
|
||
10f64.powf(db / 20.0)
|
||
}
|
||
|
||
#[inline]
|
||
fn lin_to_db(v: f64) -> f64 {
|
||
20.0 * v.max(1e-12).log10()
|
||
}
|
||
|
||
/// One-pole smoothing coefficient for a time constant of `ms` milliseconds.
|
||
fn smoothing(ms: f64, sr: f64) -> f64 {
|
||
if ms <= 0.0 { 0.0 } else { (-1.0 / (ms / 1000.0 * sr)).exp() }
|
||
}
|
||
|
||
/// Static compressor curve: gain change (dB, ≤ 0) for an input level `x` dB (quadratic soft
|
||
/// knee of width `knee` around the threshold).
|
||
pub fn compressor_gain_db(x: f64, threshold: f64, ratio: f64, knee: f64) -> f64 {
|
||
// Ratios below 1:1 expand above the threshold.
|
||
let r = ratio.max(0.1);
|
||
let over = x - threshold;
|
||
let y = if knee > 0.0 && 2.0 * over.abs() <= knee {
|
||
x + (1.0 / r - 1.0) * (over + knee / 2.0).powi(2) / (2.0 * knee)
|
||
} else if over > 0.0 {
|
||
threshold + over / r
|
||
} else {
|
||
x
|
||
};
|
||
y - x
|
||
}
|
||
|
||
/// Auto Release: a program-dependent release from two envelopes. A fast one (the Attack time, a
|
||
/// short release) follows transients; a slow one (slow attack and release) only builds up under
|
||
/// sustained compression. The deeper of the two applies, so brief peaks recover quickly and long
|
||
/// loud passages slowly.
|
||
const AUTO_RELEASE_FAST_MS: f64 = 60.0;
|
||
const AUTO_RELEASE_SLOW_ATTACK_MS: f64 = 150.0;
|
||
const AUTO_RELEASE_SLOW_MS: f64 = 900.0;
|
||
|
||
fn compressor(params: &Params, buf: &mut [f32], sr: f64) {
|
||
let th = params.f("threshold");
|
||
let ratio = params.f("ratio").max(0.1);
|
||
let knee = params.f("knee").max(0.0);
|
||
let att = smoothing(params.f("attack"), sr);
|
||
let auto = params.b("autoRelease");
|
||
let rel = smoothing(if auto { AUTO_RELEASE_FAST_MS } else { params.f("release").max(0.001) }, sr);
|
||
let (att_s, rel_s) = (smoothing(AUTO_RELEASE_SLOW_ATTACK_MS, sr), smoothing(AUTO_RELEASE_SLOW_MS, sr));
|
||
let makeup = params.f("makeupGain");
|
||
// Output Limit: a final ceiling (brick-wall clip) so the result never exceeds it.
|
||
let limit = db_to_lin(params.f("outputLimit").min(0.0)) as f32;
|
||
// Smoothed gain change in dB: attack while the reduction grows, release as it shrinks.
|
||
let (mut g, mut gs) = (0.0f64, 0.0f64);
|
||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||
let peak = f[0].abs().max(f[1].abs()) as f64;
|
||
let target = compressor_gain_db(lin_to_db(peak), th, ratio, knee);
|
||
let k = if target < g { att } else { rel };
|
||
g = target + (g - target) * k;
|
||
let mut eff = g;
|
||
if auto {
|
||
let k = if target < gs { att_s } else { rel_s };
|
||
gs = target + (gs - target) * k;
|
||
eff = g.min(gs);
|
||
}
|
||
let lin = db_to_lin(eff + makeup) as f32;
|
||
for v in f.iter_mut() {
|
||
*v = (*v * lin).clamp(-limit, limit);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn gate(params: &Params, buf: &mut [f32], sr: f64) {
|
||
let th = db_to_lin(params.f("threshold"));
|
||
let att = smoothing(params.f("attack"), sr);
|
||
let rel = smoothing(params.f("release").max(0.001), sr);
|
||
let hold = (params.f("hold").max(0.0) / 1000.0 * sr) as usize;
|
||
// Openness 0..1 (closed..open) is the gain: a closed gate mutes.
|
||
let mut g = 0.0f64;
|
||
let mut held = 0usize;
|
||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||
let peak = f[0].abs().max(f[1].abs()) as f64;
|
||
let open = if peak >= th {
|
||
held = hold;
|
||
true
|
||
} else if held > 0 {
|
||
held -= 1;
|
||
true
|
||
} else {
|
||
false
|
||
};
|
||
let target = if open { 1.0 } else { 0.0 };
|
||
let k = if target > g { att } else { rel };
|
||
g = target + (g - target) * k;
|
||
let lin = g as f32;
|
||
f[0] *= lin;
|
||
f[1] *= lin;
|
||
}
|
||
}
|
||
|
||
/// The Distortion effect's waveshaper for one sample: `kind` indexes [`DISTORTION_TYPES`],
|
||
/// `drive` (0..1) sets how hard the curve bends. The result stays within ±1.
|
||
pub fn distort_sample(kind: u32, x: f32, drive: f32) -> f32 {
|
||
let v = x * (1.0 + drive.clamp(0.0, 1.0) * 24.0);
|
||
match kind {
|
||
// Hard clip: linear up to a flat ceiling.
|
||
1 => v,
|
||
// Saturation 1: rational curve, gentler knee than tanh.
|
||
2 => v / (1.0 + v.abs()),
|
||
// Saturation 2: cubic soft saturator (odd harmonics), flat beyond ±1.5.
|
||
3 => {
|
||
let c = v.clamp(-1.5, 1.5);
|
||
c - c * c * c * 4.0 / 27.0
|
||
}
|
||
// Tube: asymmetric — the positive half saturates harder (even harmonics).
|
||
4 => {
|
||
if v >= 0.0 {
|
||
v.tanh()
|
||
} else {
|
||
(0.6 * v).tanh()
|
||
}
|
||
}
|
||
// Fuzz: near-square exponential clipper.
|
||
5 => v.signum() * (1.0 - (-3.0 * v.abs()).exp()),
|
||
// Soft clip.
|
||
_ => v.tanh(),
|
||
}
|
||
.clamp(-1.0, 1.0)
|
||
}
|
||
|
||
fn distortion(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
|
||
let kind = params.e("distortionType");
|
||
let drive = (params.f("drive") / 100.0) as f32;
|
||
// Gain: pre-distortion amplification (25 = unity); Volume: output level (11 = unity).
|
||
let pre = (params.f("gain").max(0.0) / 25.0) as f32;
|
||
let mix = (params.f("mix") / 100.0).clamp(0.0, 1.0) as f32;
|
||
let vol = (params.f("volume").max(0.0) / 11.0) as f32;
|
||
let bits = params.f("bitDepth").round().clamp(1.0, 24.0);
|
||
let q = (bits < 24.0).then(|| 2f32.powf(bits as f32 - 1.0));
|
||
let down = params.f("downsample").round().clamp(1.0, 64.0) as i64;
|
||
// Sample-and-hold on absolute sample positions, so blocks agree with one long run.
|
||
let first = (start * sr).round() as i64;
|
||
let mut held = [0.0f32; 2];
|
||
for (i, f) in buf.as_chunks_mut::<2>().0.iter_mut().enumerate() {
|
||
let take = i == 0 || (first + i as i64).rem_euclid(down) == 0;
|
||
for ch in 0..2 {
|
||
let x = f[ch];
|
||
let mut y = distort_sample(kind, x * pre, drive);
|
||
if let Some(q) = q {
|
||
y = (y * q).round() / q;
|
||
}
|
||
if take {
|
||
held[ch] = y;
|
||
}
|
||
f[ch] = (x * (1.0 - mix) + held[ch] * mix) * vol;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn lfo(kind: u32, phase: f64) -> f64 {
|
||
let ph = phase.rem_euclid(1.0);
|
||
match kind {
|
||
1 => 1.0 - 4.0 * (ph - 0.5).abs(), // triangle, −1..1
|
||
_ => (2.0 * PI * ph).sin(),
|
||
}
|
||
}
|
||
|
||
fn flange_chorus(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
|
||
let sep = params.f("voiceSeparationTime").max(0.0) / 1000.0 * sr;
|
||
let voices = params.f("voices").round().clamp(1.0, 8.0) as usize;
|
||
let rate = params.f("modulationRate").max(0.0);
|
||
let depth = (params.f("modulationDepth") / 100.0).clamp(0.0, 1.0);
|
||
let vphase = params.f("voicePhaseChange") / 360.0;
|
||
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
|
||
let stereo = params.b("stereoVoices");
|
||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||
let src = buf.to_vec();
|
||
let n = buf.len() / 2;
|
||
for i in 0..n {
|
||
let t = start + i as f64 / sr;
|
||
let mut acc = [0.0f32; 2];
|
||
let mut cnt = [0usize; 2];
|
||
for v in 0..voices {
|
||
let base = sep * (v + 1) as f64;
|
||
let d = (base * (1.0 + depth * lfo(0, rate * t + vphase * v as f64))).max(0.0);
|
||
for ch in 0..2 {
|
||
if stereo && voices > 1 && v % 2 != ch {
|
||
continue;
|
||
}
|
||
acc[ch] += read_frac(&src, i, ch, d);
|
||
cnt[ch] += 1;
|
||
}
|
||
}
|
||
for ch in 0..2 {
|
||
let w = if cnt[ch] > 0 { acc[ch] / cnt[ch] as f32 } else { 0.0 };
|
||
buf[i * 2 + ch] = src[i * 2 + ch] * dry + inv * w * wet;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn modulator(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
|
||
let kind = params.e("modulationType");
|
||
let rate = params.f("modulationRate").max(0.0);
|
||
// Depth: vibrato as a modulated delay of up to 5 ms; amplitude: tremolo.
|
||
let depth = (params.f("modulationDepth") / 100.0).clamp(0.0, 1.0) * 0.005 * sr;
|
||
let am = (params.f("amplitudeModulation") / 100.0).clamp(0.0, 1.0);
|
||
let src = buf.to_vec();
|
||
let n = buf.len() / 2;
|
||
for i in 0..n {
|
||
let t = start + i as f64 / sr;
|
||
let l = lfo(kind, rate * t);
|
||
let d = depth * (1.0 + l) * 0.5;
|
||
let g = (1.0 - am * (1.0 - l) * 0.5) as f32;
|
||
for ch in 0..2 {
|
||
let x = if depth > 0.0 { read_frac(&src, i, ch, d) } else { src[i * 2 + ch] };
|
||
buf[i * 2 + ch] = x * g;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// (comb feedback, damping, all-pass gain, pre-delay seconds)
|
||
fn reverb_params(params: &Params) -> (f64, f64, f64, f64) {
|
||
let decay = (params.f("decay") / 100.0).clamp(0.0, 1.0);
|
||
let fb = 0.7 + decay * 0.28;
|
||
let damp = (1.0 - params.f("brightness") / 100.0).clamp(0.0, 1.0) * 0.6;
|
||
let ap = 0.3 + 0.4 * (params.f("diffusion") / 100.0).clamp(0.0, 1.0);
|
||
let pre = params.f("reverbTime").max(0.0) / 1000.0;
|
||
(fb, damp, ap, pre)
|
||
}
|
||
|
||
fn reverb(params: &Params, buf: &mut [f32], sr: f64) {
|
||
const COMBS: [usize; 8] = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
|
||
const ALLPASS: [usize; 4] = [556, 441, 341, 225];
|
||
const SPREAD: usize = 23;
|
||
let (fb, damp, apg, pre) = reverb_params(params);
|
||
let dry = (params.f("dryOut") / 100.0) as f32;
|
||
let wet = (params.f("wetOut") / 100.0) as f32;
|
||
let k = sr / 44100.0;
|
||
let pre_n = (pre * sr).round() as usize;
|
||
let n = buf.len() / 2;
|
||
let src = buf.to_vec();
|
||
for ch in 0..2 {
|
||
let spread = if ch == 1 { SPREAD } else { 0 };
|
||
let mut combs: Vec<(Vec<f64>, usize, f64)> = COMBS.iter().map(|&l| (vec![0.0; (((l + spread) as f64 * k) as usize).max(1)], 0, 0.0)).collect();
|
||
let mut aps: Vec<(Vec<f64>, usize)> = ALLPASS.iter().map(|&l| (vec![0.0; (((l + spread) as f64 * k) as usize).max(1)], 0)).collect();
|
||
for i in 0..n {
|
||
let x = if i >= pre_n { (src[(i - pre_n) * 2] as f64 + src[(i - pre_n) * 2 + 1] as f64) * 0.5 * 0.03 } else { 0.0 };
|
||
let mut out = 0.0;
|
||
for (line, pos, filt) in combs.iter_mut() {
|
||
let y = line[*pos];
|
||
*filt = y * (1.0 - damp) + *filt * damp;
|
||
line[*pos] = x + *filt * fb;
|
||
*pos = (*pos + 1) % line.len();
|
||
out += y;
|
||
}
|
||
for (line, pos) in aps.iter_mut() {
|
||
let b = line[*pos];
|
||
let y = -out + b;
|
||
line[*pos] = out + b * apg;
|
||
*pos = (*pos + 1) % line.len();
|
||
out = y;
|
||
}
|
||
buf[i * 2 + ch] = src[i * 2 + ch] * dry + out as f32 * wet * 3.0;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn stereo_mixer(params: &Params, buf: &mut [f32]) {
|
||
let ll = (params.f("leftLevel") / 100.0) as f32;
|
||
let rl = (params.f("rightLevel") / 100.0) as f32;
|
||
// Linear pan law: −100 = all left, 0 = both at full, +100 = all right.
|
||
let pan = |p: f64| -> (f32, f32) {
|
||
let p = (p / 100.0).clamp(-1.0, 1.0);
|
||
((1.0 - p).min(1.0) as f32, (1.0 + p).min(1.0) as f32)
|
||
};
|
||
let (l_to_l, l_to_r) = pan(params.f("leftPan"));
|
||
let (r_to_l, r_to_r) = pan(params.f("rightPan"));
|
||
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
|
||
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
|
||
let (l, r) = (f[0] * ll, f[1] * rl);
|
||
f[0] = (l * l_to_l + r * r_to_l) * inv;
|
||
f[1] = (l * l_to_r + r * r_to_r) * inv;
|
||
}
|
||
}
|
||
|
||
fn tone(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
|
||
let kind = params.e("waveformOptions");
|
||
let level = (params.f("level") / 100.0) as f32;
|
||
let freqs: Vec<f64> = (1..=5).map(|i| params.f(&format!("frequency{i}"))).filter(|f| *f > 0.0).collect();
|
||
let n = buf.len() / 2;
|
||
for i in 0..n {
|
||
let t = start + i as f64 / sr;
|
||
let mut v = 0.0f64;
|
||
for f in &freqs {
|
||
let ph = (f * t).rem_euclid(1.0);
|
||
v += match kind {
|
||
1 => lfo(1, ph - 0.25),
|
||
2 => 2.0 * ph - 1.0,
|
||
3 => {
|
||
if ph < 0.5 {
|
||
1.0
|
||
} else {
|
||
-1.0
|
||
}
|
||
}
|
||
// White noise: a hash of the absolute sample position (every frequency alike).
|
||
4 => {
|
||
let h = ((t * sr).round() as i64 as u64 ^ f.to_bits()).wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||
let h = (h ^ (h >> 29)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||
((h ^ (h >> 32)) >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
|
||
}
|
||
_ => (2.0 * PI * ph).sin(),
|
||
};
|
||
}
|
||
let v = v as f32 * level;
|
||
buf[i * 2] += v;
|
||
buf[i * 2 + 1] += v;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn defaults(id: &str) -> Params {
|
||
let s = crate::find(id).unwrap();
|
||
Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }
|
||
}
|
||
|
||
fn sine(freq: f64, sr: f64, n: usize) -> Vec<f32> {
|
||
(0..n)
|
||
.flat_map(|i| {
|
||
let v = (2.0 * PI * freq * i as f64 / sr).sin() as f32;
|
||
[v, v]
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn rms(b: &[f32], from: usize) -> f32 {
|
||
let s: f32 = b[from * 2..].iter().map(|v| v * v).sum();
|
||
(s / (b.len() - from * 2) as f32).sqrt()
|
||
}
|
||
|
||
#[test]
|
||
fn registered_as_audio() {
|
||
for id in [
|
||
"ec.audio.backwards",
|
||
"ec.audio.basstreble",
|
||
"ec.audio.compressor",
|
||
"ec.audio.delay",
|
||
"ec.audio.distortion",
|
||
"ec.audio.gate",
|
||
"ec.audio.flangechorus",
|
||
"ec.audio.highlowpass",
|
||
"ec.audio.modulator",
|
||
"ec.audio.parametriceq",
|
||
"ec.audio.reverb",
|
||
"ec.audio.stereomixer",
|
||
"ec.audio.tone",
|
||
] {
|
||
let s = crate::find(id).unwrap_or_else(|| panic!("{id}"));
|
||
assert_eq!(s.category, "Audio");
|
||
assert!(is_audio_effect(id));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn high_low_pass_attenuates() {
|
||
let sr = 48000.0;
|
||
let mut p = defaults("ec.audio.highlowpass");
|
||
p.values.insert("filterOptions".into(), Value::Enum(1));
|
||
p.values.insert("cutoffFrequency".into(), num(500.0));
|
||
let mut hi = sine(8000.0, sr, 4800);
|
||
process("ec.audio.highlowpass", &p, &mut hi, 48000, 0.0);
|
||
let mut lo = sine(100.0, sr, 4800);
|
||
process("ec.audio.highlowpass", &p, &mut lo, 48000, 0.0);
|
||
assert!(rms(&hi, 1000) < 0.01, "{}", rms(&hi, 1000));
|
||
assert!(rms(&lo, 1000) > 0.6, "{}", rms(&lo, 1000));
|
||
// High pass does the opposite.
|
||
p.values.insert("filterOptions".into(), Value::Enum(0));
|
||
let mut lo = sine(50.0, sr, 9600);
|
||
process("ec.audio.highlowpass", &p, &mut lo, 48000, 0.0);
|
||
assert!(rms(&lo, 2000) < 0.02);
|
||
}
|
||
|
||
#[test]
|
||
fn delay_offsets_impulse() {
|
||
let mut p = defaults("ec.audio.delay");
|
||
p.values.insert("delayTime".into(), num(10.0));
|
||
p.values.insert("feedback".into(), num(0.0));
|
||
let mut b = vec![0.0f32; 2000];
|
||
b[0] = 1.0;
|
||
b[1] = 1.0;
|
||
process("ec.audio.delay", &p, &mut b, 10000, 0.0);
|
||
// dry 75 %, then the echo at 100 samples: wet 75 % × amount 50 %.
|
||
assert!((b[0] - 0.75).abs() < 1e-6);
|
||
assert!((b[200] - 0.375).abs() < 1e-6 && (b[201] - 0.375).abs() < 1e-6);
|
||
assert!(b[2..200].iter().all(|v| *v == 0.0));
|
||
assert!(preroll("ec.audio.delay", &p) >= 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn stereo_mixer_pans() {
|
||
let mut p = defaults("ec.audio.stereomixer");
|
||
let mut b = vec![1.0, 0.0, 1.0, 0.0];
|
||
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
|
||
assert_eq!(b, vec![1.0, 0.0, 1.0, 0.0], "defaults are identity");
|
||
p.values.insert("leftPan".into(), num(100.0));
|
||
let mut b = vec![1.0, 0.0];
|
||
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
|
||
assert_eq!(b, vec![0.0, 1.0]);
|
||
p.values.insert("leftPan".into(), num(0.0));
|
||
p.values.insert("leftLevel".into(), num(50.0));
|
||
let mut b = vec![1.0, 0.0];
|
||
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
|
||
assert_eq!(b, vec![0.5, 0.5]);
|
||
}
|
||
|
||
#[test]
|
||
fn tone_generates_frequency() {
|
||
let mut p = defaults("ec.audio.tone");
|
||
p.values.insert("frequency1".into(), num(1000.0));
|
||
for i in 2..=5 {
|
||
p.values.insert(format!("frequency{i}"), num(0.0));
|
||
}
|
||
let sr = 48000;
|
||
let mut b = vec![0.0f32; 48000 * 2];
|
||
process("ec.audio.tone", &p, &mut b, sr, 0.0);
|
||
let left: Vec<f32> = b.iter().step_by(2).copied().collect();
|
||
let crossings = left.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
|
||
assert!((crossings as i64 - 1000).abs() <= 1, "{crossings}");
|
||
let peak = left.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||
assert!((peak - 0.2).abs() < 1e-3, "{peak}");
|
||
}
|
||
|
||
#[test]
|
||
fn bass_treble_eq_reverb_modulator_flange() {
|
||
let sr = 48000.0;
|
||
// Bass boost raises 60 Hz, treble cut lowers 10 kHz.
|
||
let mut p = defaults("ec.audio.basstreble");
|
||
p.values.insert("bass".into(), num(100.0));
|
||
p.values.insert("treble".into(), num(-100.0));
|
||
let mut lo = sine(60.0, sr, 9600);
|
||
process("ec.audio.basstreble", &p, &mut lo, 48000, 0.0);
|
||
assert!(rms(&lo, 4000) > 0.707 * 3.0, "{}", rms(&lo, 4000));
|
||
let mut hi = sine(10000.0, sr, 9600);
|
||
process("ec.audio.basstreble", &p, &mut hi, 48000, 0.0);
|
||
assert!(rms(&hi, 4000) < 0.707 * 0.4, "{}", rms(&hi, 4000));
|
||
// Parametric EQ boost at its centre.
|
||
let mut p = defaults("ec.audio.parametriceq");
|
||
p.values.insert("band1BoostCut".into(), num(12.0));
|
||
p.values.insert("band1Frequency".into(), num(1000.0));
|
||
let mut s = sine(1000.0, sr, 9600);
|
||
process("ec.audio.parametriceq", &p, &mut s, 48000, 0.0);
|
||
assert!((rms(&s, 4000) * std::f32::consts::SQRT_2 - 3.98).abs() < 0.2, "{}", rms(&s, 4000) * std::f32::consts::SQRT_2);
|
||
// Reverb leaves a tail after an impulse.
|
||
let p = defaults("ec.audio.reverb");
|
||
let mut b = vec![0.0f32; 48000];
|
||
b[0] = 1.0;
|
||
b[1] = 1.0;
|
||
process("ec.audio.reverb", &p, &mut b, 48000, 0.0);
|
||
assert!(b[2 * 12000..].iter().any(|v| v.abs() > 1e-5));
|
||
assert!(b.iter().all(|v| v.is_finite() && v.abs() < 2.0));
|
||
// Modulator with amplitude modulation dips the level; flange keeps it bounded.
|
||
let mut p = defaults("ec.audio.modulator");
|
||
p.values.insert("amplitudeModulation".into(), num(100.0));
|
||
let mut b = vec![1.0f32; 9600];
|
||
process("ec.audio.modulator", &p, &mut b, 48000, 0.0);
|
||
assert!(b.iter().any(|v| *v < 0.05) && b.iter().any(|v| *v > 0.95));
|
||
let p = defaults("ec.audio.flangechorus");
|
||
let mut b = sine(440.0, sr, 4800);
|
||
process("ec.audio.flangechorus", &p, &mut b, 48000, 0.0);
|
||
assert!(b.iter().all(|v| v.abs() <= 1.0 + 1e-4));
|
||
}
|
||
|
||
#[test]
|
||
fn backwards_swaps_channels() {
|
||
let mut p = defaults("ec.audio.backwards");
|
||
p.values.insert("swapChannels".into(), Value::Bool(true));
|
||
let mut b = vec![1.0, 2.0];
|
||
process("ec.audio.backwards", &p, &mut b, 48000, 0.0);
|
||
assert_eq!(b, vec![2.0, 1.0]);
|
||
assert!(reverses("ec.audio.backwards"));
|
||
}
|
||
|
||
#[test]
|
||
fn compressor_reduces_gain_above_threshold() {
|
||
// Static curve: 10 dB over a −20 dB threshold at 4:1 comes out 7.5 dB lower.
|
||
assert!((compressor_gain_db(-10.0, -20.0, 4.0, 0.0) + 7.5).abs() < 1e-9);
|
||
assert_eq!(compressor_gain_db(-30.0, -20.0, 4.0, 0.0), 0.0);
|
||
// The soft knee meets the hard curve at its edges.
|
||
let k = 6.0;
|
||
assert!(compressor_gain_db(-23.0, -20.0, 4.0, k).abs() < 1e-9);
|
||
assert!((compressor_gain_db(-17.0, -20.0, 4.0, k) - compressor_gain_db(-17.0, -20.0, 4.0, 0.0)).abs() < 1e-9);
|
||
let sr = 48000.0;
|
||
let mut p = defaults("ec.audio.compressor");
|
||
for (k, v) in [("attack", 1.0), ("threshold", -20.0), ("ratio", 4.0), ("knee", 0.0), ("release", 100.0)] {
|
||
p.values.insert(k.into(), num(v));
|
||
}
|
||
p.values.insert("autoRelease".into(), Value::Bool(false));
|
||
let mut loud = sine(1000.0, sr, 9600);
|
||
process("ec.audio.compressor", &p, &mut loud, 48000, 0.0);
|
||
// A full-scale sine (peak 0 dB) is pulled down by ~15 dB once the envelope settles.
|
||
let peak = loud[18000..].iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||
let want = db_to_lin(compressor_gain_db(0.0, -20.0, 4.0, 0.0)) as f32;
|
||
assert!((peak - want).abs() < 0.05, "{peak} vs {want}");
|
||
// Quiet input passes untouched.
|
||
let mut quiet: Vec<f32> = sine(1000.0, sr, 4800).iter().map(|v| v * 0.01).collect();
|
||
let orig = quiet.clone();
|
||
process("ec.audio.compressor", &p, &mut quiet, 48000, 0.0);
|
||
assert!(quiet.iter().zip(&orig).all(|(a, b)| (a - b).abs() < 1e-6));
|
||
}
|
||
|
||
#[test]
|
||
fn gate_mutes_below_threshold() {
|
||
let sr = 48000.0;
|
||
let mut p = defaults("ec.audio.gate");
|
||
p.values.insert("threshold".into(), num(-40.0));
|
||
// A −60 dB signal under the −40 dB threshold is muted.
|
||
let mut quiet: Vec<f32> = sine(500.0, sr, 9600).iter().map(|v| v * 0.001).collect();
|
||
process("ec.audio.gate", &p, &mut quiet, 48000, 0.0);
|
||
assert!(rms(&quiet, 4800) < 1e-6, "{}", rms(&quiet, 4800));
|
||
// A loud signal opens the gate.
|
||
let mut loud: Vec<f32> = sine(500.0, sr, 9600).iter().map(|v| v * 0.5).collect();
|
||
let r0 = rms(&loud, 4800);
|
||
process("ec.audio.gate", &p, &mut loud, 48000, 0.0);
|
||
assert!((rms(&loud, 4800) - r0).abs() < 0.01);
|
||
// Deterministic.
|
||
let mut a = sine(300.0, sr, 2000);
|
||
let mut b = a.clone();
|
||
process("ec.audio.gate", &p, &mut a, 48000, 0.0);
|
||
process("ec.audio.gate", &p, &mut b, 48000, 0.0);
|
||
assert_eq!(a, b);
|
||
}
|
||
|
||
#[test]
|
||
fn distortion_shapes_and_bounds() {
|
||
assert!((distort_sample(0, 0.5, 0.0) - 0.5f32.tanh()).abs() < 1e-6);
|
||
assert_eq!(distort_sample(1, 0.8, 1.0), 1.0);
|
||
for k in 0..DISTORTION_TYPES.len() as u32 {
|
||
for x in [-2.0f32, -0.3, 0.0, 0.2, 1.7] {
|
||
let y = distort_sample(k, x, 0.5);
|
||
assert!(y.abs() <= 1.0 && y.signum() * x.signum() >= 0.0, "{k} {x} {y}");
|
||
}
|
||
}
|
||
// Tube is asymmetric.
|
||
assert!(distort_sample(4, 0.1, 0.5) > -distort_sample(4, -0.1, 0.5));
|
||
let p = defaults("ec.audio.distortion");
|
||
let mut b = sine(200.0, 48000.0, 4800);
|
||
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
|
||
assert!(b.iter().all(|v| v.abs() <= 1.0));
|
||
assert!(rms(&b, 100) > 0.1);
|
||
assert!(preroll("ec.audio.compressor", &defaults("ec.audio.compressor")) > 0.4);
|
||
}
|
||
|
||
#[test]
|
||
fn distortion_mix_resolution_and_downsample() {
|
||
let sr = 48000.0;
|
||
let src: Vec<f32> = sine(300.0, sr, 480).iter().map(|v| v * 0.5).collect();
|
||
// Mix 0 with unity volume passes the input through.
|
||
let mut p = defaults("ec.audio.distortion");
|
||
p.values.insert("mix".into(), num(0.0));
|
||
let mut b = src.clone();
|
||
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
|
||
assert!(b.iter().zip(&src).all(|(a, s)| (a - s).abs() < 1e-6));
|
||
// 2-bit resolution leaves at most 5 distinct levels (−1, −½, 0, ½, 1).
|
||
let mut p = defaults("ec.audio.distortion");
|
||
p.values.insert("bitDepth".into(), num(2.0));
|
||
let mut b = src.clone();
|
||
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
|
||
let mut levels: Vec<i32> = b.iter().map(|v| (v * 1000.0).round() as i32).collect();
|
||
levels.sort();
|
||
levels.dedup();
|
||
assert!(levels.len() <= 5, "{levels:?}");
|
||
// Downsample ×4 holds each value for 4 frames.
|
||
let mut p = defaults("ec.audio.distortion");
|
||
p.values.insert("downsample".into(), num(4.0));
|
||
let mut b = src.clone();
|
||
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
|
||
assert_eq!(b[0], b[6]);
|
||
assert_ne!(b[6], b[8]);
|
||
}
|
||
|
||
#[test]
|
||
fn compressor_output_limit_and_auto_release() {
|
||
let sr = 48000.0;
|
||
let mut p = defaults("ec.audio.compressor");
|
||
p.values.insert("outputLimit".into(), num(-12.0));
|
||
p.values.insert("makeupGain".into(), num(20.0));
|
||
let mut b = sine(1000.0, sr, 4800);
|
||
process("ec.audio.compressor", &p, &mut b, 48000, 0.0);
|
||
let ceiling = db_to_lin(-12.0) as f32;
|
||
assert!(b.iter().all(|v| v.abs() <= ceiling + 1e-6));
|
||
// After a short burst, auto release recovers faster than a long manual release.
|
||
let burst = |auto: bool| {
|
||
let mut p = defaults("ec.audio.compressor");
|
||
p.values.insert("autoRelease".into(), Value::Bool(auto));
|
||
p.values.insert("release".into(), num(4000.0));
|
||
let mut b: Vec<f32> = sine(1000.0, sr, 9600).iter().enumerate().map(|(i, v)| if i < 960 { *v } else { v * 0.2 }).collect();
|
||
process("ec.audio.compressor", &p, &mut b, 48000, 0.0);
|
||
rms(&b, 4800)
|
||
};
|
||
assert!(burst(true) > burst(false) * 1.2, "{} {}", burst(true), burst(false));
|
||
}
|
||
}
|