Skip to content

Commit d7635a7

Browse files
committed
web: lush ambient reverb/delay buses with per-voice sends; master bus mute; animated ring particles + vignette; expand instance capacity; docs update
1 parent 7030f21 commit d7635a7

File tree

5 files changed

+203
-14
lines changed

5 files changed

+203
-14
lines changed

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ web-sys = { version = "0.3", features = [
4444
"PannerNode",
4545
"PanningModelType",
4646
"DistanceModelType",
47+
"AudioBuffer",
48+
"ConvolverNode",
49+
"DelayNode",
50+
"BiquadFilterNode",
51+
"BiquadFilterType",
4752
] }
4853
rand = "0.8"
4954
smallvec = "1.13"

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66

77
- Web front-end (WASM) is running with:
88
- 3 voices, spatial audio (Web Audio + PannerNode)
9+
- Lush ambient effects: global Convolver reverb and dark feedback Delay bus with per-voice sends and a master bus
910
- Start overlay to initialize audio (Click Start; canvas-click fallback)
1011
- Drag voices in XZ plane; click to mute, Shift+Click reseed, Alt+Click solo
1112
- Keyboard: R (reseed all), Space (pause), + / - (tempo), M (master mute), O (orbit on/off)
12-
- Starts muted by default; press M to unmute
13+
- Starts muted by default; press M to unmute the master bus
1314
- Dynamic hint shows current BPM, paused, and muted state
15+
- Rich visuals: instanced voice markers with emissive pulses, animated orbiting ring particles, subtle vignette, optional analyser-driven spectrum dots
1416
- Native front-end renders and plays basic synthesized audio (parity improving)
1517

1618
### Requirements

crates/app-core/shaders/scene.wgsl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,22 @@ fn vs_main(
2929

3030
@fragment
3131
fn fs_main(inf: VsOut) -> @location(0) vec4<f32> {
32-
// Circular mask within the quad (unit circle of radius 0.5)
32+
// Circular mask within the quad (unit circle of radius 0.5)
3333
let r = length(inf.local);
3434
let shape_alpha = 1.0 - smoothstep(0.48, 0.5, r);
3535

36-
// Emissive pulse boosts brightness subtly
36+
// Emissive pulse boosts brightness subtly
3737
let emissive = 0.7 * clamp(inf.pulse, 0.0, 1.5);
3838
var rgb = inf.color.rgb * (1.0 + emissive);
3939

4040
// Subtle outer glow/halo based on radius
4141
let halo = smoothstep(0.75, 0.55, r) * 0.12; // outer ring brightness
4242
rgb += halo * inf.color.rgb;
4343

44+
// Add vignette-like radial darkening for ambient mood
45+
let vignette = smoothstep(0.0, 0.7, r);
46+
rgb *= mix(1.0, 0.85, vignette);
47+
4448
return vec4<f32>(rgb, shape_alpha * inf.color.a);
4549
}
4650

crates/app-web/src/lib.rs

Lines changed: 185 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,138 @@ async fn init() -> anyhow::Result<()> {
194194
);
195195
}
196196

197-
// Per-voice master gains -> destination
197+
// Master mix bus -> destination
198+
let master_gain = match web::GainNode::new(&audio_ctx) {
199+
Ok(g) => g,
200+
Err(e) => {
201+
log::error!("Master GainNode error: {:?}", e);
202+
return;
203+
}
204+
};
205+
master_gain.gain().set_value(0.8);
206+
if let Err(e) = master_gain.connect_with_audio_node(&audio_ctx.destination()) {
207+
log::error!("connect error: {:?}", e);
208+
return;
209+
}
210+
211+
// Global lush reverb (Convolver) and tempo-synced dark delay bus
212+
// Reverb input and wet level
213+
let reverb_in = match web::GainNode::new(&audio_ctx) {
214+
Ok(g) => g,
215+
Err(e) => {
216+
log::error!("Reverb in GainNode error: {:?}", e);
217+
return;
218+
}
219+
};
220+
reverb_in.gain().set_value(1.0);
221+
let reverb = match web::ConvolverNode::new(&audio_ctx) {
222+
Ok(n) => n,
223+
Err(e) => {
224+
log::error!("ConvolverNode error: {:?}", e);
225+
return;
226+
}
227+
};
228+
reverb.set_normalize(true);
229+
// Create a long, dark stereo impulse response procedurally
230+
{
231+
let sr = audio_ctx.sample_rate();
232+
let seconds = 5.0_f32; // lush tail
233+
let len = (sr as f32 * seconds) as u32;
234+
if let Ok(ir) = audio_ctx.create_buffer(2, len, sr) {
235+
// simple xorshift32 for deterministic noise
236+
let mut seed_l: u32 = 0x1234ABCD;
237+
let mut seed_r: u32 = 0x7890FEDC;
238+
for ch in 0..2 {
239+
let mut buf: Vec<f32> = vec![0.0; len as usize];
240+
let mut t = 0.0_f32;
241+
let dt = 1.0_f32 / sr as f32;
242+
for i in 0..len as usize {
243+
let s = if ch == 0 { &mut seed_l } else { &mut seed_r };
244+
let mut x = *s;
245+
x ^= x << 13;
246+
x ^= x >> 17;
247+
x ^= x << 5;
248+
*s = x;
249+
let n = ((x as f32 / std::u32::MAX as f32) * 2.0 - 1.0) as f32;
250+
// Exponential decay envelope, dark tilt
251+
let decay = (-t / 3.0).exp();
252+
let dark = (1.0 - (t / seconds)).max(0.0);
253+
let v = n * decay * (0.6 + 0.4 * dark);
254+
buf[i] = v;
255+
t += dt;
256+
}
257+
let _ = ir.copy_to_channel(&mut buf, ch as i32);
258+
}
259+
reverb.set_buffer(Some(&ir));
260+
}
261+
}
262+
let reverb_wet = match web::GainNode::new(&audio_ctx) {
263+
Ok(g) => g,
264+
Err(e) => {
265+
log::error!("Reverb wet GainNode error: {:?}", e);
266+
return;
267+
}
268+
};
269+
reverb_wet.gain().set_value(0.6);
270+
let _ = reverb_in.connect_with_audio_node(&reverb);
271+
let _ = reverb.connect_with_audio_node(&reverb_wet);
272+
let _ = reverb_wet.connect_with_audio_node(&master_gain);
273+
274+
// Delay bus with feedback loop and lowpass tone for darkness
275+
let delay_in = match web::GainNode::new(&audio_ctx) {
276+
Ok(g) => g,
277+
Err(e) => {
278+
log::error!("Delay in GainNode error: {:?}", e);
279+
return;
280+
}
281+
};
282+
delay_in.gain().set_value(1.0);
283+
let delay = match audio_ctx.create_delay_with_max_delay_time(3.0) {
284+
Ok(n) => n,
285+
Err(e) => {
286+
log::error!("DelayNode error: {:?}", e);
287+
return;
288+
}
289+
};
290+
// Around ~3/8 to ~1/2 note depending on BPM 110 → ~0.55s feels lush
291+
delay.delay_time().set_value(0.55);
292+
let delay_tone = match web::BiquadFilterNode::new(&audio_ctx) {
293+
Ok(n) => n,
294+
Err(e) => {
295+
log::error!("BiquadFilterNode error: {:?}", e);
296+
return;
297+
}
298+
};
299+
delay_tone.set_type(web::BiquadFilterType::Lowpass);
300+
delay_tone.frequency().set_value(1400.0);
301+
let delay_feedback = match web::GainNode::new(&audio_ctx) {
302+
Ok(g) => g,
303+
Err(e) => {
304+
log::error!("Delay feedback GainNode error: {:?}", e);
305+
return;
306+
}
307+
};
308+
delay_feedback.gain().set_value(0.6);
309+
let delay_wet = match web::GainNode::new(&audio_ctx) {
310+
Ok(g) => g,
311+
Err(e) => {
312+
log::error!("Delay wet GainNode error: {:?}", e);
313+
return;
314+
}
315+
};
316+
delay_wet.gain().set_value(0.5);
317+
let _ = delay_in.connect_with_audio_node(&delay);
318+
let _ = delay.connect_with_audio_node(&delay_tone);
319+
let _ = delay_tone.connect_with_audio_node(&delay_feedback);
320+
let _ = delay_feedback.connect_with_audio_node(&delay);
321+
let _ = delay_tone.connect_with_audio_node(&delay_wet);
322+
let _ = delay_wet.connect_with_audio_node(&master_gain);
323+
324+
// Per-voice master gains -> master bus, plus effect sends
198325
let mut voice_gains: Vec<web::GainNode> = Vec::new();
199326
let mut voice_panners: Vec<web::PannerNode> = Vec::new();
327+
let mut delay_sends_vec: Vec<web::GainNode> = Vec::new();
328+
let mut reverb_sends_vec: Vec<web::GainNode> = Vec::new();
200329
for v in 0..engine.borrow().voices.len() {
201330
let panner = match web::PannerNode::new(&audio_ctx) {
202331
Ok(p) => p,
@@ -225,13 +354,36 @@ async fn init() -> anyhow::Result<()> {
225354
log::error!("connect error: {:?}", e);
226355
return;
227356
}
228-
if let Err(e) = panner.connect_with_audio_node(&audio_ctx.destination()) {
357+
if let Err(e) = panner.connect_with_audio_node(&master_gain) {
229358
log::error!("connect error: {:?}", e);
230359
return;
231360
}
361+
// Per-voice sends
362+
let d_send = match web::GainNode::new(&audio_ctx) {
363+
Ok(g) => g,
364+
Err(e) => {
365+
log::error!("Delay send GainNode error: {:?}", e);
366+
return;
367+
}
368+
};
369+
d_send.gain().set_value(0.4);
370+
let _ = d_send.connect_with_audio_node(&delay_in);
371+
delay_sends_vec.push(d_send);
372+
let r_send = match web::GainNode::new(&audio_ctx) {
373+
Ok(g) => g,
374+
Err(e) => {
375+
log::error!("Reverb send GainNode error: {:?}", e);
376+
return;
377+
}
378+
};
379+
r_send.gain().set_value(0.65);
380+
let _ = r_send.connect_with_audio_node(&reverb_in);
381+
reverb_sends_vec.push(r_send);
232382
voice_gains.push(gain);
233383
voice_panners.push(panner);
234384
}
385+
let delay_sends = Rc::new(delay_sends_vec);
386+
let reverb_sends = Rc::new(reverb_sends_vec);
235387

236388
// Initialize WebGPU (leak a canvas clone to satisfy 'static lifetime for surface)
237389
let leaked_canvas = Box::leak(Box::new(canvas_for_click_inner.clone()));
@@ -430,6 +582,7 @@ async fn init() -> anyhow::Result<()> {
430582
let master_muted_k = master_muted.clone();
431583
let orbit_enabled_k = orbit_enabled.clone();
432584
let voice_gains_k = voice_gains.clone();
585+
let master_gain_k = master_gain.clone();
433586
let window = web::window().unwrap();
434587
let closure = Closure::wrap(Box::new(move |ev: web::KeyboardEvent| {
435588
let key = ev.key();
@@ -546,10 +699,8 @@ async fn init() -> anyhow::Result<()> {
546699
"m" | "M" => {
547700
let mut muted = master_muted_k.borrow_mut();
548701
*muted = !*muted;
549-
let new_val = if *muted { 0.0 } else { 0.2 };
550-
for g in voice_gains_k.iter() {
551-
g.gain().set_value(new_val);
552-
}
702+
let new_val = if *muted { 0.0 } else { 0.8 };
703+
master_gain_k.gain().set_value(new_val);
553704
log::info!("[keys] master muted={}", *muted);
554705
// If hint visible, refresh its content
555706
if let Some(win) = web::window() {
@@ -713,6 +864,8 @@ async fn init() -> anyhow::Result<()> {
713864
let hover_tick = hover_index.clone();
714865
let canvas_for_tick = canvas_for_click_inner.clone();
715866
let voice_gains_tick = voice_gains.clone();
867+
let delay_sends_tick = delay_sends.clone();
868+
let reverb_sends_tick = reverb_sends.clone();
716869
// Optional slow camera orbit
717870
let mut orbit_t: f32 = 0.0;
718871
let orbit_tick = orbit_enabled.clone();
@@ -809,6 +962,25 @@ async fn init() -> anyhow::Result<()> {
809962
BASE_SCALE + ps[2] * SCALE_PULSE_MULTIPLIER,
810963
];
811964

965+
// Orbiting ring particles around each voice center
966+
let two_pi = std::f32::consts::PI * 2.0;
967+
let ring_count = 48usize;
968+
for vi in 0..3 {
969+
let center = positions[vi];
970+
let base_col = Vec3::from(e_ref.configs[vi].color_rgb);
971+
let ring_r = 0.9 + ps[vi] * 0.25;
972+
for j in 0..ring_count {
973+
let a =
974+
orbit_t * 0.8 + (j as f32) * (two_pi / ring_count as f32);
975+
let offset = Vec3::new(a.cos() * ring_r, 0.0, a.sin() * ring_r);
976+
positions.push(center + offset);
977+
let c = base_col * 0.55;
978+
colors.push(Vec4::from((c, 0.9)));
979+
let s = 0.06 + 0.04 * ((j % 12) as f32 / 12.0);
980+
scales.push(s);
981+
}
982+
}
983+
812984
// Optional analyser-driven dot spectrum row
813985
if let Some(a) = &analyser {
814986
let bins = a.frequency_bin_count() as usize;
@@ -903,6 +1075,11 @@ async fn init() -> anyhow::Result<()> {
9031075
let _ = src.connect_with_audio_node(&gain);
9041076
let _ =
9051077
gain.connect_with_audio_node(&voice_gains_tick[ev.voice_index]);
1078+
// Effect sends per note
1079+
let _ =
1080+
gain.connect_with_audio_node(&delay_sends_tick[ev.voice_index]);
1081+
let _ = gain
1082+
.connect_with_audio_node(&reverb_sends_tick[ev.voice_index]);
9061083

9071084
let _ = src.start_with_when(t0);
9081085
let _ = src.stop_with_when(t0 + ev.duration_sec as f64 + 0.02);
@@ -1079,10 +1256,10 @@ impl<'a> GpuState<'a> {
10791256
contents: bytemuck::cast_slice(&quad_vertices),
10801257
usage: wgpu::BufferUsages::VERTEX,
10811258
});
1082-
// Instance buffer (capacity for 32 instances)
1259+
// Instance buffer (capacity for many instances: 3 voices + rings + spectrum)
10831260
let instance_vb = device.create_buffer(&wgpu::BufferDescriptor {
10841261
label: Some("instance_vb"),
1085-
size: (std::mem::size_of::<InstanceData>() * 32) as u64,
1262+
size: (std::mem::size_of::<InstanceData>() * 1024) as u64,
10861263
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
10871264
mapped_at_creation: false,
10881265
});

docs/TODO.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,17 @@ This checklist tracks progress against the high-level plan in `docs/SPEC.md` and
2323
- [x] Controls: BPM, reseed voice/all, mute, solo (click/keys)
2424
- [x] Distance attenuation via `PannerNode` with drag movement
2525
- [x] Optional `AnalyserNode` to drive ambient visuals
26+
- [x] Master bus with lush `ConvolverNode` reverb and dark feedback `DelayNode` bus with lowpass tone shaping; per-voice sends
2627
- [ ] Optional AudioWorklet path (future)
2728

2829
## Visual Engine (Web)
2930

3031
- [x] Instanced rendering of voice markers (circle mask, emissive pulse)
3132
- [x] Audio-reactive pulses on note events
32-
- [ ] Ambient visuals (particles/spectrum bars/lights)
33+
- [x] Ambient visuals (animated ring particles, optional analyser-driven spectrum dots)
3334
- [x] Optional camera orbit (toggle 'O')
34-
- [x] Sync listener orientation with camera
35-
- [ ] Visual polish (colors, easing, subtle glow)
35+
- [x] Sync listener orientation with camera
36+
- [x] Visual polish (colors, easing, subtle glow, vignette)
3637
- [x] Prefer SRGB surface format where available (e.g., BGRA8UnormSrgb)
3738

3839
## Interaction & UI (Web)

0 commit comments

Comments
 (0)