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",
]
[[package]]
name = "audioshader"
version = "0.1.0"
dependencies = [
"bevy",
"cpal 0.16.0",
"crossbeam-channel",
"rodio 0.21.1",
"rustfft",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@ -1134,16 +1145,6 @@ dependencies = [
"wasm-bindgen-futures",
]
[[package]]
name = "bevy_test"
version = "0.1.0"
dependencies = [
"bevy",
"cpal 0.16.0",
"crossbeam-channel",
"rodio 0.21.1",
]
[[package]]
name = "bevy_text"
version = "0.16.1"
@ -3031,6 +3032,15 @@ dependencies = [
"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]]
name = "num-derive"
version = "0.4.2"
@ -3610,6 +3620,15 @@ dependencies = [
"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]]
name = "proc-macro-crate"
version = "3.3.0"
@ -3834,6 +3853,20 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rustix"
version = "0.38.44"
@ -4054,6 +4087,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "strum"
version = "0.26.3"
@ -4462,6 +4501,16 @@ dependencies = [
"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]]
name = "ttf-parser"
version = "0.20.0"

View file

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

View file

@ -3,46 +3,105 @@
@group(2) @binding(0) var<uniform> u_time: f32;
@group(2) @binding(1) var<uniform> u_resolution: 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 {
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
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
// c = -0.743643887037151 + 0.13182590420533i
let center = vec2f(-0.743643887037151, 0.13182590420533);
let zoomSpeed: f32 = 0.2; // tune this to control how fast we zoom
let zoom = exp(u_time * zoomSpeed); // zoom factor grows with time
let c = center + (uv - center) / zoom; // move coordinates toward center as zoom increases
// Polar coords
let r = length(p);
let ang = atan2(p.y, p.x); // [-pi, pi]
var a01 = ang / 6.2831853 + 0.5; // [0,1]
var zx = 0.0;
var zy = 0.0;
let maxIter: i32 = 100 + i32(u_time);
var it: i32 = 0;
// Scroll the angle slowly for motion
a01 = fract(a01 + 0.05 * u_time);
for (var i: i32 = 0; i < maxIter; i = i + 1) {
let x = zx * zx - zy * zy + c.x;
let y = 2.0 * zx * zy + c.y;
zx = x;
zy = y;
if (zx * zx + zy * zy > 4.0) {
it = i;
break;
}
}
// Log-like frequency remap for better distribution of low freqs
let freq = pow(a01, 0.8);
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);
if (t < 0.999999) {
color = palette;
}
return vec4f(color, 1);
// Ring intensity with derivative-based anti-aliasing
let band_w = mix(0.02, 0.06, 1.0 - amp);
let aa = fwidth(r);
let dist = abs(r - ring_r);
let ring = 1.0 - smoothstep(band_w - aa, band_w + aa, dist);
// 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},
},
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
/// sample rate and channel layout.
#[derive(Event, Debug, Clone)]
@ -16,19 +36,6 @@ pub struct AudioCaptureData {
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.
#[derive(Resource)]
struct AudioCaptureStream {
@ -44,6 +51,7 @@ pub struct AudioCapturePlugin;
impl Plugin for AudioCapturePlugin {
fn build(&self, app: &mut App) {
app.add_event::<AudioCaptureData>()
.init_resource::<AudioFft>()
.add_systems(Startup, init_audio_input_stream)
.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");
info!(
"Mic stream started: {} ch @ {} Hz (default OS input)",
"Audio capture stream started: {} ch @ {} Hz (default OS input)",
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.
while let Ok(samples) = mic.rx.try_recv() {
writer.write(AudioCaptureData {
samples,
samples: samples.clone(),
sample_rate: mic.sample_rate,
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,
(
material::update_material_time,
audio_input::handle_audio_capture_events,
material::update_material_audio_fft,
systems::screen_resized,
systems::mouse_moved,
systems::exit_app.run_if(input_just_pressed(KeyCode::Escape)),

View file

@ -1,7 +1,8 @@
use bevy::{
asset::Assets,
prelude::*,
reflect::TypePath,
render::render_resource::{AsBindGroup, ShaderRef},
render::render_resource::{AsBindGroup, Extent3d, ShaderRef},
sprite::{AlphaMode2d, Material2d},
};
@ -15,6 +16,9 @@ pub struct CustomMaterial {
pub resolution: Vec2,
#[uniform(2)]
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>) {
@ -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 {
fn fragment_shader() -> ShaderRef {
DEFAULT_SHADER_PATH.into()

View file

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