This commit is contained in:
Matthew Deville 2025-09-05 10:12:51 +02:00
parent 55a94f0a75
commit 8389e88a80
7 changed files with 250 additions and 58 deletions

69
Cargo.lock generated
View file

@ -275,6 +275,17 @@ dependencies = [
"portable-atomic-util", "portable-atomic-util",
] ]
[[package]]
name = "audioshader"
version = "0.1.0"
dependencies = [
"bevy",
"cpal 0.16.0",
"crossbeam-channel",
"rodio 0.21.1",
"rustfft",
]
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@ -1134,16 +1145,6 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
] ]
[[package]]
name = "bevy_test"
version = "0.1.0"
dependencies = [
"bevy",
"cpal 0.16.0",
"crossbeam-channel",
"rodio 0.21.1",
]
[[package]] [[package]]
name = "bevy_text" name = "bevy_text"
version = "0.16.1" version = "0.16.1"
@ -3031,6 +3032,15 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-derive" name = "num-derive"
version = "0.4.2" version = "0.4.2"
@ -3610,6 +3620,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.3.0" version = "3.3.0"
@ -3834,6 +3853,20 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustfft"
version = "6.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f140db74548f7c9d7cce60912c9ac414e74df5e718dc947d514b051b42f3f4"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.44" version = "0.38.44"
@ -4054,6 +4087,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]] [[package]]
name = "strum" name = "strum"
version = "0.26.3" version = "0.26.3"
@ -4462,6 +4501,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.20.0" version = "0.20.0"

View file

@ -1,6 +1,6 @@
[package] [package]
edition = "2024" edition = "2024"
name = "bevy_test" name = "audioshader"
version = "0.1.0" version = "0.1.0"
@ -9,6 +9,7 @@
cpal = "0.16" cpal = "0.16"
crossbeam-channel = "0.5" crossbeam-channel = "0.5"
rodio = "0.21" rodio = "0.21"
rustfft = "6"
# Enable a small amount of optimization in the dev profile. # Enable a small amount of optimization in the dev profile.
[profile.dev] [profile.dev]

View file

@ -3,46 +3,105 @@
@group(2) @binding(0) var<uniform> u_time: f32; @group(2) @binding(0) var<uniform> u_time: f32;
@group(2) @binding(1) var<uniform> u_resolution: vec2f; @group(2) @binding(1) var<uniform> u_resolution: vec2f;
@group(2) @binding(2) var<uniform> u_mouse_position: vec2f; @group(2) @binding(2) var<uniform> u_mouse_position: vec2f;
@group(2) @binding(3) var fft_texture: texture_2d<f32>;
@group(2) @binding(4) var fft_texture_sampler: sampler;
fn lerp(v0: f32, v1: f32, t: f32) -> f32 { fn lerp(v0: f32, v1: f32, t: f32) -> f32 {
return v0 + t * (v1 - v0); return v0 + t * (v1 - v0);
} }
// Utility: clamp to 0..1 range
fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }
// Cosine palette (like IQ's)
fn palette(t: f32, a: vec3f, b: vec3f, c: vec3f, d: vec3f) -> vec3f {
return a + b * cos(6.2831853 * (c * t + d));
}
// Sample the FFT texture at normalized x in [0,1]
fn sample_fft(x: f32) -> f32 {
// 5-tap Gaussian blur in texture space using actual texel width
let size = textureDimensions(fft_texture);
let tex_w = max(f32(size.x), 1.0);
let one_texel = 1.0 / tex_w;
let u = saturate(x);
let v = 0.5;
let w0 = 0.0625; // gaussian-ish weights: 1,4,6,4,1 normalized
let w1 = 0.25;
let w2 = 0.375;
let s0 = textureSample(fft_texture, fft_texture_sampler, vec2f(u - 2.0 * one_texel, v)).r;
let s1 = textureSample(fft_texture, fft_texture_sampler, vec2f(u - 1.0 * one_texel, v)).r;
let s2 = textureSample(fft_texture, fft_texture_sampler, vec2f(u, v)).r;
let s3 = textureSample(fft_texture, fft_texture_sampler, vec2f(u + 1.0 * one_texel, v)).r;
let s4 = textureSample(fft_texture, fft_texture_sampler, vec2f(u + 2.0 * one_texel, v)).r;
return w0 * s0 + w1 * s1 + w2 * s2 + w1 * s3 + w0 * s4;
}
@fragment @fragment
fn fragment(mesh: VertexOutput) -> @location(0) vec4f { fn fragment(mesh: VertexOutput) -> @location(0) vec4f {
var uv = vec2f(lerp(-2, 0.47, mesh.uv.x), lerp(-1.12, 1.12, mesh.uv.y)); // Normalize UV to -1..1 with aspect correction
let aspect = u_resolution.x / max(u_resolution.y, 1.0);
let p = (mesh.uv * 2.0 - vec2f(1.0, 1.0)) * vec2f(aspect, 1.0);
// Progressive time-based zoom toward the known interesting point // Polar coords
// c = -0.743643887037151 + 0.13182590420533i let r = length(p);
let center = vec2f(-0.743643887037151, 0.13182590420533); let ang = atan2(p.y, p.x); // [-pi, pi]
let zoomSpeed: f32 = 0.2; // tune this to control how fast we zoom var a01 = ang / 6.2831853 + 0.5; // [0,1]
let zoom = exp(u_time * zoomSpeed); // zoom factor grows with time
let c = center + (uv - center) / zoom; // move coordinates toward center as zoom increases
var zx = 0.0; // Scroll the angle slowly for motion
var zy = 0.0; a01 = fract(a01 + 0.05 * u_time);
let maxIter: i32 = 100 + i32(u_time);
var it: i32 = 0;
for (var i: i32 = 0; i < maxIter; i = i + 1) { // Log-like frequency remap for better distribution of low freqs
let x = zx * zx - zy * zy + c.x; let freq = pow(a01, 0.8);
let y = 2.0 * zx * zy + c.y;
zx = x;
zy = y;
if (zx * zx + zy * zy > 4.0) {
it = i;
break;
}
}
let t = f32(it) / f32(maxIter); // Smooth FFT sample with a few taps
let amp0 = sample_fft(freq);
let amp1 = sample_fft(saturate(freq + 1.0 / 512.0));
let amp2 = sample_fft(saturate(freq - 1.0 / 512.0));
var amp = (amp0 + amp1 + amp2) / 3.0;
// Optional shaping
amp = pow(amp, 0.6);
let palette = vec3f(0.5) + 0.5 * cos(6.2831853 * (vec3f(0.0, 0.33, 0.66) + t * 1.5) + u_time * 0.2); // Target ring radius driven by amplitude
let base_r = 0.35;
let ring_r = base_r + amp * 0.45;
var color = vec3f(0, 0, 0); // Ring intensity with derivative-based anti-aliasing
if (t < 0.999999) { let band_w = mix(0.02, 0.06, 1.0 - amp);
color = palette; let aa = fwidth(r);
} let dist = abs(r - ring_r);
let ring = 1.0 - smoothstep(band_w - aa, band_w + aa, dist);
return vec4f(color, 1); // Radial spokes modulated by FFT at harmonics
let spokes = 0.5 + 0.5 * cos(ang * (8.0 + 24.0 * amp) + u_time * (1.0 + 2.0 * amp));
let spokes_mask = pow(spokes, 3.0);
// Glow falloff
let glow = 0.015 / (r * r + 0.03);
// Background subtle gradient with slow drift
let bg = 0.08 + 0.06 * cos(6.28318 * (p.x * 0.15 + p.y * 0.12 + 0.03 * u_time));
// Color palette over angle
let col = palette(
a01,
vec3f(0.35, 0.3, 0.35), // a
vec3f(0.35, 0.35, 0.35), // b
vec3f(1.0, 1.0, 1.0), // c
vec3f(0.0, 0.33, 0.67) // d
);
// Combine layers
var color = vec3f(bg) * 0.6;
color += col * ring * (0.6 + 0.6 * spokes_mask);
color += vec3f(0.9, 0.95, 1.0) * glow * (0.3 + 1.5 * amp);
// Subtle vignette
let vig = smoothstep(1.2, 0.3, r);
color *= vig;
// Final boost
color = clamp(color, vec3f(0.0), vec3f(1.0));
return vec4f(color, 1.0);
} }

View file

@ -5,8 +5,28 @@ use {
traits::{DeviceTrait, HostTrait, StreamTrait}, traits::{DeviceTrait, HostTrait, StreamTrait},
}, },
crossbeam_channel::{Receiver, Sender}, crossbeam_channel::{Receiver, Sender},
rustfft::{Fft, FftPlanner, num_complex::Complex},
std::sync::Arc,
}; };
/// Resource holding the latest FFT result (magnitude spectrum)
#[derive(Resource, Clone, Debug)]
pub struct AudioFft {
pub spectrum: Vec<f32>,
pub sample_rate: u32,
pub channels: u16,
}
impl Default for AudioFft {
fn default() -> Self {
Self {
spectrum: vec![0.0; 1024],
sample_rate: 44100,
channels: 1,
}
}
}
/// Event carrying interleaved mic samples as f32 in the device's native /// Event carrying interleaved mic samples as f32 in the device's native
/// sample rate and channel layout. /// sample rate and channel layout.
#[derive(Event, Debug, Clone)] #[derive(Event, Debug, Clone)]
@ -16,19 +36,6 @@ pub struct AudioCaptureData {
pub channels: u16, pub channels: u16,
} }
pub fn handle_audio_capture_events(mut reader: EventReader<AudioCaptureData>) {
for event in reader.read() {
// Process mic data here.
// For example, print the number of samples received.
println!(
"Received mic data: {} samples at {} Hz, {} channels",
event.samples.len(),
event.sample_rate,
event.channels
);
}
}
/// Holds the CPAL stream alive and a channel for transferring audio to Bevy world. /// Holds the CPAL stream alive and a channel for transferring audio to Bevy world.
#[derive(Resource)] #[derive(Resource)]
struct AudioCaptureStream { struct AudioCaptureStream {
@ -44,6 +51,7 @@ pub struct AudioCapturePlugin;
impl Plugin for AudioCapturePlugin { impl Plugin for AudioCapturePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_event::<AudioCaptureData>() app.add_event::<AudioCaptureData>()
.init_resource::<AudioFft>()
.add_systems(Startup, init_audio_input_stream) .add_systems(Startup, init_audio_input_stream)
.add_systems(Update, dispatch_audio_events); .add_systems(Update, dispatch_audio_events);
} }
@ -77,7 +85,7 @@ fn init_audio_input_stream(mut commands: Commands) {
stream.play().expect("Failed to play input stream"); stream.play().expect("Failed to play input stream");
info!( info!(
"Mic stream started: {} ch @ {} Hz (default OS input)", "Audio capture stream started: {} ch @ {} Hz (default OS input)",
channels, sample_rate channels, sample_rate
); );
@ -124,13 +132,54 @@ where
) )
} }
fn dispatch_audio_events(mic: Res<AudioCaptureStream>, mut writer: EventWriter<AudioCaptureData>) { fn dispatch_audio_events(
mic: Res<AudioCaptureStream>,
mut writer: EventWriter<AudioCaptureData>,
mut fft_res: ResMut<AudioFft>,
) {
// FFT parameters
const FFT_SIZE: usize = 1024;
// Drain any available audio buffers without blocking the frame. // Drain any available audio buffers without blocking the frame.
while let Ok(samples) = mic.rx.try_recv() { while let Ok(samples) = mic.rx.try_recv() {
writer.write(AudioCaptureData { writer.write(AudioCaptureData {
samples, samples: samples.clone(),
sample_rate: mic.sample_rate, sample_rate: mic.sample_rate,
channels: mic.channels, channels: mic.channels,
}); });
// Compute FFT on the first channel (mono mix)
let mut mono: Vec<f32> = if mic.channels > 1 {
samples
.chunks(mic.channels as usize)
.map(|frame| frame[0])
.collect()
} else {
samples.clone()
};
// Pad or truncate to FFT_SIZE
if mono.len() < FFT_SIZE {
mono.resize(FFT_SIZE, 0.0);
} else if mono.len() > FFT_SIZE {
mono.truncate(FFT_SIZE);
}
// Prepare input for FFT
let mut buffer: Vec<Complex<f32>> = mono
.into_iter()
.map(|x| Complex { re: x, im: 0.0 })
.collect();
// Compute FFT
let mut planner = FftPlanner::<f32>::new();
let fft: Arc<dyn Fft<f32>> = planner.plan_fft_forward(FFT_SIZE);
fft.process(&mut buffer);
// Compute magnitude spectrum
let spectrum: Vec<f32> = buffer.iter().map(|c| c.norm()).collect();
// Update AudioFft resource
fft_res.spectrum = spectrum;
fft_res.sample_rate = mic.sample_rate;
fft_res.channels = mic.channels;
} }
} }

View file

@ -19,7 +19,7 @@ fn main() {
Update, Update,
( (
material::update_material_time, material::update_material_time,
audio_input::handle_audio_capture_events, material::update_material_audio_fft,
systems::screen_resized, systems::screen_resized,
systems::mouse_moved, systems::mouse_moved,
systems::exit_app.run_if(input_just_pressed(KeyCode::Escape)), systems::exit_app.run_if(input_just_pressed(KeyCode::Escape)),

View file

@ -1,7 +1,8 @@
use bevy::{ use bevy::{
asset::Assets,
prelude::*, prelude::*,
reflect::TypePath, reflect::TypePath,
render::render_resource::{AsBindGroup, ShaderRef}, render::render_resource::{AsBindGroup, Extent3d, ShaderRef},
sprite::{AlphaMode2d, Material2d}, sprite::{AlphaMode2d, Material2d},
}; };
@ -15,6 +16,9 @@ pub struct CustomMaterial {
pub resolution: Vec2, pub resolution: Vec2,
#[uniform(2)] #[uniform(2)]
pub mouse_position: Vec2, pub mouse_position: Vec2,
#[texture(3)]
#[sampler(4)]
pub audio_fft_tex: Handle<Image>,
} }
pub fn update_material_time(mut materials: ResMut<Assets<CustomMaterial>>, time: Res<Time>) { pub fn update_material_time(mut materials: ResMut<Assets<CustomMaterial>>, time: Res<Time>) {
@ -24,6 +28,35 @@ pub fn update_material_time(mut materials: ResMut<Assets<CustomMaterial>>, time:
} }
} }
pub fn update_material_audio_fft(
mut materials: ResMut<Assets<CustomMaterial>>,
mut images: ResMut<Assets<Image>>,
audio_fft: Res<crate::audio_input::AudioFft>,
) {
let spectrum = &audio_fft.spectrum;
if spectrum.is_empty() {
return;
}
let image = Image::new(
Extent3d {
width: spectrum.len() as u32,
height: 1,
depth_or_array_layers: 1,
},
bevy::render::render_resource::TextureDimension::D2,
spectrum
.iter()
.map(|&v| (v * 255.0).clamp(0.0, 255.0) as u8)
.collect(),
bevy::render::render_resource::TextureFormat::R8Unorm,
bevy::asset::RenderAssetUsages::RENDER_WORLD,
);
let handle = images.add(image);
for (_handle, material) in materials.iter_mut() {
material.audio_fft_tex = handle.clone();
}
}
impl Material2d for CustomMaterial { impl Material2d for CustomMaterial {
fn fragment_shader() -> ShaderRef { fn fragment_shader() -> ShaderRef {
DEFAULT_SHADER_PATH.into() DEFAULT_SHADER_PATH.into()

View file

@ -26,6 +26,7 @@ pub fn setup(
time: 0.0, time: 0.0,
resolution: screen_size, resolution: screen_size,
mouse_position: Vec2::ZERO, mouse_position: Vec2::ZERO,
audio_fft_tex: Default::default(),
}); });
commands.spawn(( commands.spawn((