Skip to content
Draft
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
14 changes: 14 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ bevy_reflect = [
]
# Enables the wasm-bindgen feature for the CPAL backend
wasm-bindgen = ["firewheel-cpal/wasm-bindgen"]
# NOTE: these glam deps will also be maintenance burden, in an upstream future we should
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree

# only use the bevy glam version and not make it optional.
# Enables `glam::Vec2` and `glam::Vec3` parameter derives for glam 0.29.
glam-29 = ["firewheel-core/glam-29"]
# Enables `glam::Vec2` and `glam::Vec3` parameter derives for glam 0.30.
Expand Down Expand Up @@ -198,6 +200,7 @@ members = [
]

[workspace.dependencies]
# if we upstream firewheel to bevy, these should become deps on bevy_log
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree

tracing = "0.1"
tracing-subscriber = "0.3"
log = "0.4"
Expand All @@ -216,6 +219,17 @@ firewheel-macros = { path = "crates/firewheel-macros", version = "0.10.0" }
bevy_platform = { version = "0.18", default-features = false, features = [
"alloc",
] }
# NOTE: depending on bevy_ecs means upstreaming bevy_seedling requires upstreaming firewheel itself,
# because otherwise publishing a release becomes impossible: seedling depends on firewheel with bevy
# feature enabled, which effectively creates a dep cycle. Assuming we want to upstream firewheel to
# bevy, the problem now becomes how to deal with the crate structure, since there's 8 crates.
# The alternative is to make bevy_ecs depend on firewheel and move the Component impls to bevy_ecs,
# but that is a non-starter.
# My proposal for the path forward is to have a bevy_firewheel crate in bevy/crates, which houses a copy
# of the firewheel top level crate and all firewheel subcrates (with names unchanged), as well as the
# original top level firewheel crate. bevy_firewheel would bring in all the subcrates with bevy integration
# enabled, and the original top level firewheel crate will be the way to use firewheel without bevy.
Comment on lines +228 to +231
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that i wrote this on the plane before discussion after posting my writeup, this is still one of the possible plans but is not my #1 anymore

# Versioning will become lockstep with bevy versions for all firewheel crates.
Copy link
Copy Markdown
Owner

@BillyDM BillyDM Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could also consider merging the crates to as few as possible. The only one that really needs to be separate is firewheel-cpal (assuming we keep the audio backend abstraction).

firewheel-core was originally meant to be like a "stable node API" that third party node developers would base on. But if we are planning to upstream Firewheel to Bevy anyway, then I suppose it doesn't make sense to have this stability guarantee anymore.

bevy_ecs = { version = "0.18", default-features = false }
bevy_reflect = { version = "0.18", default-features = false }
num-traits = { version = "0.2", default-features = false }
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
A mid-level open source audio graph engine for games and other applications, written in Rust.

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).
NOTE: I disagree that this is like wgpu for audio. This is a step higher than wgpu, since it provides DSP infrastructure. This is more akin
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I should change that.

to bevy_render, and cpal is more like wgpu in this analogy. ALSA/JACK/WASAPI/ASIO/CoreAudio, etc are equivalent to Vulkan/Metal/DirectX/OpenGL/WebGPU etc.

## Key Features

Expand Down
1 change: 1 addition & 0 deletions assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions crates/firewheel-core/src/dsp/distance_attenuation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ impl DistanceAttenuatorStereoDsp {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting! I might have to try that.

//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down
3 changes: 3 additions & 0 deletions crates/firewheel-core/src/dsp/interleave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub fn deinterleave<V: AsMut<[f32]>>(
&mut channels[0].as_mut()[start_frame_in_channels..start_frame_in_channels + samples];

ch.copy_from_slice(interleaved);
// TODO: should this silence check use epsilon
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, that would be a handy feature to optimize microphone inputs. I'll add that!


if calculate_silence_mask {
if ch.iter().find(|&&s| s != 0.0).is_none() {
Expand All @@ -56,6 +57,7 @@ pub fn deinterleave<V: AsMut<[f32]>>(
}

if calculate_silence_mask {
// TODO: should this silence check use epsilon
for (ch_i, ch) in channels.iter_mut().enumerate() {
if ch.as_mut()[0..samples]
.iter()
Expand All @@ -81,6 +83,7 @@ pub fn deinterleave<V: AsMut<[f32]>>(
{
*out_s = in_chunk[ch_i];
}
// TODO: should this silence check use epsilon

if calculate_silence_mask && ch_i < 64 {
if ch.iter().find(|&&s| s != 0.0).is_none() {
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-core/src/dsp/mix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ impl MixDSP {
}
}

// TODO: method duplication
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for the duplication is to have optimized auto-vectorized loops for the most common use cases. Though I suppose those optimized loops could all be in a single function.

pub fn mix_first_into_second_stereo(
&mut self,
first_l: &[f32],
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-core/src/dsp/volume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ impl Default for DbMeterNormalizer {
pub fn is_buffer_silent(buffer: &[f32], amp_epsilon: f32) -> bool {
let mut silent = true;
for &s in buffer.iter() {
// TODO: check if this autovectorizes
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears to auto-vectorize https://rust.godbolt.org/z/xGen4r59a

if s.abs() > amp_epsilon {
silent = false;
break;
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-core/src/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ impl RealtimeLogger {
pub fn try_debug(&mut self, message: &str) -> Result<(), RealtimeLogError> {
#[cfg(debug_assertions)]
{
// TODO: code duplication
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the duplication here?

if message.len() > self.max_msg_length {
self.shared_state
.message_too_long_occured
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-core/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ pub struct ProcBuffers<'a, 'b> {
/// Each channel slice will have a length of [`ProcInfo::frames`].
///
/// These buffers may contain junk data.
// TODO: This is UB; having references to uninitialized data is not allowed. Use MaybeUninit or raw pointers.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, by "junk data" I just meant that it can contain data from previous process loops (this is a very common thing in the audio processing world, clearing the buffers for every node would be a lot of extra processing overhead). The buffers are all initialized to zero before being sent to the audio thread, so there is no safety concern here. I'll make that clearer in the docs.

pub outputs: &'a mut [&'b mut [f32]],
}

Expand Down
2 changes: 2 additions & 0 deletions crates/firewheel-cpal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,7 @@ struct DataCallback {
processor: Option<FirewheelProcessor<CpalBackend>>,
sample_rate: u32,
sample_rate_recip: f64,
// Why are these commented?
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a leftover from the PLEASE FIX ME below. I'll remove it.

//_first_internal_clock_instant: Option<cpal::StreamInstant>,
//_prev_stream_instant: Option<cpal::StreamInstant>,
predicted_delta_time: Duration,
Expand Down Expand Up @@ -917,6 +918,7 @@ impl DataCallback {

// TODO: PLEASE FIX ME:
//
// TODO: investigate CPAL
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm at a bit of a loss on this one. Might have to make an issue in the CPAL repository.

// It appears that for some reason, both Windows and Linux will sometimes return a timestamp which
// has a value less than the previous timestamp. I am unsure if this is a bug with the APIs, a bug
// with CPAL, or I'm just misunderstaning how the timestamps are supposed to be used. Either way,
Expand Down
2 changes: 2 additions & 0 deletions crates/firewheel-graph/src/graph/compiler/schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,9 @@ fn sum_inputs(
.set_silent(all_buffers_silent, frames as u16);
}

// TODO: this must be marked as unsafe, and invariants justified at every callsite.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The safety here is that this is a private function only accessible to this module. Rather than copy-pasting the same long safety message for every single call site, I just put it into a separate function.

#[inline]
// TODO: this should use an UnsafeCell type for interior mutability. This allow is not acceptable
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, that makes sense. I'll make that change!

#[allow(clippy::mut_from_ref)]
fn buffer_slice_mut<'a>(
buffers: &'a [f32],
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-nodes/src/convolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ impl<const CHANNELS: usize> AudioNodeProcessor for ConvolutionProcessor<CHANNELS
DeclickFadeCurve::EqualPower3dB,
);

// TODO: double check this epsilon choice
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this isn't the correct epsilon choice. It should be using firewheel_core::dsp::volume::DEFAULT_AMP_EPSILON

buffers.check_for_silence_on_outputs(f32::EPSILON)
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-nodes/src/fast_filters/bandpass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ impl<const CHANNELS: usize> AudioNodeProcessor for Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-nodes/src/fast_filters/highpass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ impl<const CHANNELS: usize> AudioNodeProcessor for Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-nodes/src/fast_filters/lowpass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ impl<const CHANNELS: usize> AudioNodeProcessor for Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down
1 change: 1 addition & 0 deletions crates/firewheel-nodes/src/freeverb/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ impl AudioNodeProcessor for FreeverbProcessor {
// the threshold for "completely silent"

// threshold chosen by ear
// TODO: double check this
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this might be better as a constant in the module.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or actually, maybe it should use firewheel_core::dsp::volume::DEFAULT_AMP_EPSILON

let threshold = 0.0001;
if matches!(
buffers.check_for_silence_on_outputs(threshold),
Expand Down
10 changes: 10 additions & 0 deletions crates/firewheel-nodes/src/svf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -727,6 +728,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -776,6 +778,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -825,6 +828,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -869,6 +873,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -920,6 +925,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -966,6 +972,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -1012,6 +1019,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -1057,6 +1065,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down Expand Up @@ -1101,6 +1110,7 @@ impl<const CHANNELS: usize> Processor<CHANNELS> {
// this can be used to only recalculate them every few frames.
//
// TODO: use core::hint::cold_path() once that stabilizes
// TODO: instead, we can extract a function and use #[cold] + #[inline(always)] today
//
// TODO: Alternatively, this could be optimized using a lookup table
if self.coeff_update_mask.do_update(i) {
Expand Down