Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
[![Crates.io](https://img.shields.io/crates/v/firewheel.svg)](https://crates.io/crates/firewheel)
[![License](https://img.shields.io/crates/l/firewheel.svg)](https://github.com/BillyDM/firewheel/blob/main/LICENSE-APACHE)

A mid-level open source audio graph engine for games and other applications, written in Rust.
An open source audio graph engine for games and other applications, written in Rust. It can be used as-is, or it can be used as a base for other higher-level audio engines.

This crate can be used as-is or as a base for other higher-level audio engines. (Think of it like [wgpu](https://wgpu.rs/) but for audio).
## The Future of this Project

Firewheel is currently planned to be upstreamed into the [Bevy](https://bevy.org/) game engine where it will become the default audio engine. (The current implementation can be found in the [bevy_seedling](https://github.com/CorvusPrudens/bevy_seedling) repository.) The goal is to both make Bevy integration easier by avoiding circular dependencies, and to ease the maintanance burden of a project that has experienced more feature creep than what the author originally anticipated.

While development has shifted to focus more on the needs of Bevy, the core audio graph engine will still be available to use outside of Bevy without any other Bevy dependencies (except for the very lightweight [bevy_platform](https://crates.io/crates/bevy_platform) dependency).

## Key Features

Expand All @@ -21,18 +25,14 @@ This crate can be used as-is or as a base for other higher-level audio engines.
* Fault tolerance for audio streams (The game shouldn't stop or crash just because the player accidentally unplugged their headphones.)
* Properly respects realtime constraints (no mutexes!)
* `no_std` compatibility (some features require the standard library)
* (TODO) Basic [CLAP] plugin hosting (non-WASM only), allowing for more open source and proprietary 3rd party effects and synths
* (TODO) Bindings for C, and (possibly) C++ and C#

## Non-features

While Firewheel is meant to cover nearly every use case for games and other applications, it is not meant to be a complete DAW (digital audio workstation) engine. Not only would this greatly increase complexity, but the needs of game audio engines and DAW audio engines are in conflict. (See the design document for more details on why).
While Firewheel aims to cover most use cases for games and other generic applications, it does *NOT* aim to be a complete DAW (digital audio workstation) engine. Not only would this greatly increase complexity, but the needs of game audio engines and DAW audio engines are in conflict. (See the design document for more details on why).

## Get Involved

Join the discussion in the [Firewheel Discord Server](https://discord.gg/rKzZpjGCGs) or in the [Bevy Discord Server](https://discord.gg/bevy) under the `working-groups -> Better Audio` channel!

If you are interested in contributing code, first read the [Design Document] and then visit the [Project Board](https://github.com/users/BillyDM/projects/1).
Join the discussion in the [Bevy Discord Server](https://discord.gg/bevy) under the `working-groups -> Better Audio` channel!

## License

Expand Down
2 changes: 1 addition & 1 deletion crates/firewheel-core/src/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ impl ArcGc<dyn Any + Send + Sync + 'static, GlobalRtGc> {
/// Construct a type-erased [`ArcGc`].
///
/// ```
/// # use rtgc::*;
/// # use firewheel-core::collector::ArcGC;
/// # use std::sync::Arc;
/// # use std::any::Any;
/// let value: ArcGc<dyn Any + Send + Sync + 'static> =
Expand Down
5 changes: 3 additions & 2 deletions crates/firewheel-core/src/dsp/distance_attenuation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::{
coeff_update::{CoeffUpdateFactor, CoeffUpdateMask},
filter::single_pole_iir::{OnePoleIirLPFCoeff, OnePoleIirLPFCoeffSimd, OnePoleIirLPFSimd},
},
param::smoother::{SmoothedParam, SmootherConfig},
param::smoother::{DEFAULT_GAIN_SPAN, SmoothedParam, SmootherConfig},
};

pub const MUFFLE_CUTOFF_HZ_MIN: f32 = 20.0;
Expand Down Expand Up @@ -203,9 +203,10 @@ impl DistanceAttenuatorStereoDsp {
coeff_update_factor: CoeffUpdateFactor,
) -> Self {
Self {
gain: SmoothedParam::new(1.0, smoother_config, sample_rate),
gain: SmoothedParam::new(1.0, DEFAULT_GAIN_SPAN, smoother_config, sample_rate),
muffle_cutoff_hz: SmoothedParam::new(
MUFFLE_CUTOFF_HZ_MAX,
MUFFLE_CUTOFF_HZ_MAX - MUFFLE_CUTOFF_HZ_MIN,
smoother_config,
sample_rate,
),
Expand Down
172 changes: 162 additions & 10 deletions crates/firewheel-core/src/dsp/filter/smoothing_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@ use core::num::NonZeroU32;

/// The default number of seconds for a [`Smoothing Filter`].
///
/// This value is chosen to be roughly equal to a typical block size
/// of 1024 samples (23 ms) to eliminate stair-stepping for most
/// games.
pub const DEFAULT_SMOOTH_SECONDS: f32 = 23.0 / 1_000.0;
pub const DEFAULT_SETTLE_EPSILON: f32 = 0.001f32;
/// This value is chosen to where the stair-stepping effect isn't noticeable for a
/// typical block size of 1024 samples.
pub const DEFAULT_SMOOTH_SECONDS: f32 = 62.0 / 1_000.0;

/// The default settle ratio value for a [`SmoothingFilter`].
pub const DEFAULT_SETTLE_RATIO: f32 = 0.01;

/// The minimum supported settle ratio value for a [`SmoothingFilter`].
///
/// Values smaller than this can tend to never settle correctly due to floating point
/// accumulation errors.
pub const MIN_SETTLE_RATIO: f32 = 0.00075;
/// The maximum supported settle ratio value for a [`SmoothingFilter`].
///
/// Values larger than this can tend to never settle correctly due to floating point
/// accumulation errors.
pub const MAX_SETTLE_RATIO: f32 = 0.9;

/// The coefficients for a simple smoothing/declicking filter where:
///
Expand All @@ -21,10 +33,60 @@ pub struct SmoothingFilterCoeff {
}

impl SmoothingFilterCoeff {
pub fn new(sample_rate: NonZeroU32, smooth_secs: f32) -> Self {
let smooth_secs = smooth_secs.max(0.00001);
/// Calculate the coefficients for a [`SmoothingFilter`].
///
/// * `sample_rate` - The sample rate of the signal.
/// * `smooth_secs` - The amount of time in seconds it takes for the filter to smooth
/// from one value to another.
/// * If less than 0.0, then 0.0 will be used.
/// * `settle_ratio` - The threshold at which the filter is considered "settled".
/// For example `0.01` means that the filter is considered settled if the value is
/// within 1% of the total span of the parameter's range. Must be >=
/// [`MIN_SETTLE_RATIO`] (0.00075) and <= [`MAX_SETTLE_RATIO`] (0.9).
/// * Will be clamped to the range `[0.00075..0.9]`.
///
/// Returns `true` if this filter is settled, `false` if not.
pub fn new(sample_rate: NonZeroU32, smooth_secs: f32, settle_ratio: f32) -> Self {
let smooth_secs = smooth_secs.max(0.0);
let ratio = settle_ratio.clamp(MIN_SETTLE_RATIO, MAX_SETTLE_RATIO);

let b1 = (-1.0f32 / (smooth_secs * sample_rate.get() as f32)).exp();
// The b1 coefficient of a one pole lp filter is given by:
//
// b1 = e ^ (-1 / t_to_1_over_e)
//
// where t_to_1_over_e is the amount of time in frames for an impulse signal
// to decay to 1/e (about 36.8%).
//
// So, to find the coefficients where an impulse signal decays to a "ratio"
// in "t_to_ratio" frames, we need to adjust the t_to_1_over_e value.
// Because doubling the time is equivalent to decaying by another 36.8%, we
// can relate ratio and t_to_ratio with:
//
// ratio = (1/e) ^ c
// t_to_ratio = t_to_1_over_e * c
//
// where c is some unknown constant.
//
// Solve for c:
//
// c = log_(1/e)(ratio)
// = -ln(ratio)
//
// Solve for t_to_1_over_e:
//
// t_to_1_over_e = t_to_ratio / c
// = t_to_ratio * (1 / -ln(ratio))
// = -ln(ratio) / t_to_ratio
//
// Which finally gives us:
//
// b1 = e ^ (-1 / (-ln(ratio) / t_to_ratio))
// = e ^ (ln(ratio) / t_to_ratio)
// = ratio ^ (1 / t_to_ratio)

let t_to_ratio = (smooth_secs * sample_rate.get() as f32).max(1.0);

let b1 = ratio.powf(t_to_ratio.recip());
let a0 = 1.0f32 - b1;

Self { a0, b1 }
Expand Down Expand Up @@ -71,11 +133,28 @@ impl SmoothingFilter {

/// Settle the filter if its state is close enough to the target value.
///
/// * `target` - The target value that is being smoothed to.
/// * `value_span` - The size of this parameter's range.
/// * If the minimum and maximum values of the parameter are known, then
/// typically `max_value - min_value` should be used.
/// * If the min and/or max values are not known, then use a span value that is
/// typical to be the worst-case-scenario (For example, if creating a "gain"
/// parameter, a good value to use is [`DEFAULT_GAIN_SPAN`] (`2.0`) since
/// immediately jumping from 0% volume to 200% volume or vice versa is typically
/// the worst-case-scenario).
/// * This value does not need to be positive.
/// * `settle_ratio` - The threshold at which the filter is considered "settled".
/// For example `0.01` means that the filter is considered settled if the value is
/// within 1% of the total `value_span`.
///
/// Returns `true` if this filter is settled, `false` if not.
pub fn settle(&mut self, target: f32, settle_epsilon: f32) -> bool {
///
/// [`DEFAULT_GAIN_SPAN`]: crate::param::smoother::DEFAULT_GAIN_SPAN
pub fn try_settle(&mut self, target: f32, value_span: f32, settle_ratio: f32) -> bool {
if self.z1 == target {
true
} else if (self.z1 - target).abs() < (target.abs() * settle_epsilon) + settle_epsilon {
} else if value_span == 0.0 || (self.z1 - target).abs() < (value_span * settle_ratio).abs()
{
self.z1 = target;
true
} else {
Expand All @@ -87,3 +166,76 @@ impl SmoothingFilter {
self.z1 == target
}
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn smoothing_filter_decay() {
const TEST_SAMPLE_RATES: [u32; 2] = [44100, 48000];
const TEST_SECONDS: [f32; 6] = [
0.0,
1.0 / 1_000.0,
5.0 / 1_000.0,
DEFAULT_SMOOTH_SECONDS,
100.0 / 1_000.0,
500.0 / 1_000.0,
];
const TEST_RATIOS: [f32; 6] = [MIN_SETTLE_RATIO, 0.001, 0.01, 0.1, 0.5, MAX_SETTLE_RATIO];
const TEST_VALUES: [(f32, f32); 5] = [
(1.0, 0.0),
(0.0, 1.0),
(1.0, -1.0),
(20.0, 20480.0),
(20480.0, 20.0),
];

let mut max_error = 0.0;

for sr in TEST_SAMPLE_RATES {
for secs in TEST_SECONDS {
for ratio in TEST_RATIOS {
for (start_value, end_value) in TEST_VALUES {
let coeff =
SmoothingFilterCoeff::new(NonZeroU32::new(sr).unwrap(), secs, ratio);
let mut filter = SmoothingFilter::new(start_value);

let mut frames: u32 = 0;
while !filter.try_settle(end_value, (start_value - end_value).abs(), ratio)
&& frames < sr * 4
{
let _ = filter.process(end_value, coeff);
frames += 1;
}

let expected_frames = ((secs * sr as f32).round() as u32).max(1);

let diff = (frames as f32 - expected_frames as f32).abs();

// Don't consider off by one frame to be an error.
let diff = if diff < 2.0 { 0.0 } else { diff };

let error = diff / expected_frames as f32;

max_error = error.max(max_error);

// Give some leeway for floating point accumulation errors.
const ERROR_TOLERANCE: f32 = 0.02;

assert!(
error < ERROR_TOLERANCE,
"error {} is >= the maximum accepted error {} | expected_frames {}, got frames {}",
error,
ERROR_TOLERANCE,
expected_frames,
frames
);
}
}
}
}

dbg!(max_error);
}
}
6 changes: 3 additions & 3 deletions crates/firewheel-core/src/dsp/mix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::{
diff::{Diff, EventQueue, Patch, PatchError, PathBuilder},
dsp::fade::FadeCurve,
event::ParamData,
param::smoother::{SmoothedParam, SmootherConfig},
param::smoother::{DEFAULT_GAIN_SPAN, SmoothedParam, SmootherConfig},
};

/// A value representing the mix between two audio signals (e.g. second/first mix)
Expand Down Expand Up @@ -131,8 +131,8 @@ impl MixDSP {
let (gain_0, gain_1) = mix.compute_gains(fade_curve);

Self {
gain_0: SmoothedParam::new(gain_0, config, sample_rate),
gain_1: SmoothedParam::new(gain_1, config, sample_rate),
gain_0: SmoothedParam::new(gain_0, DEFAULT_GAIN_SPAN, config, sample_rate),
gain_1: SmoothedParam::new(gain_1, DEFAULT_GAIN_SPAN, config, sample_rate),
}
}

Expand Down
24 changes: 19 additions & 5 deletions crates/firewheel-core/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,25 @@ impl<'a, 'b> ProcBuffers<'a, 'b> {
ProcessStatus::OutputsModified
}
}

/// Returns `true` if the input signal has settled to silence by the end
/// of the block.
pub fn inputs_settled_at_zero(&self) -> bool {
let mut settled_at_zero = true;

for ch in self.inputs.iter() {
// Check the last two samples instead of just one since it is
// incredibly unlikely that an active signal has two exact
// zeros in a row.
settled_at_zero = ch.iter().rev().take(2).all(|s| *s == 0.0);

if !settled_at_zero {
break;
}
}

settled_at_zero
}
}

/// Extra buffers and utilities for [`AudioNodeProcessor::process`]
Expand Down Expand Up @@ -714,11 +733,6 @@ pub struct ProcInfo {
/// nodes in the graph.
pub out_connected_mask: ConnectedMask,

/// If the previous processing block had all output buffers silent
/// (or if this is the first processing block), then this will be
/// `true`. Otherwise, this will be `false`.
pub prev_output_was_silent: bool,

/// The sample rate of the audio stream in samples per second.
pub sample_rate: NonZeroU32,

Expand Down
Loading
Loading