Skip to content

Commit b526ebd

Browse files
committed
Web: default lower volume with ArrowUp/Down control; tempo on ArrowLeft/Right; Start overlay shows key map; remove 'h' help toggle. Native: master volume with ArrowUp/Down (+/=, NumpadAdd/Subtract). Update README to reflect controls.
1 parent e379467 commit b526ebd

File tree

4 files changed

+121
-51
lines changed

4 files changed

+121
-51
lines changed

README.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
### Project status
66

7-
- Web front-end (WASM) is running with:
7+
- Web front-end (WASM) is running with:
88
- 3 voices, spatial audio (Web Audio + PannerNode)
99
- Lush ambient effects: global Convolver reverb and dark feedback Delay bus with per-voice sends and a master bus
1010
- Mouse-driven FX: corner-based saturation (clean ↔ fizz) and opposite-corner delay emphasis; visuals have inertial swirl motion and click ripples
1111
- Note-driven visuals use attack/release smoothing for organic response (no abrupt jumps)
1212
- Start overlay to initialize audio (Click Start; canvas-click fallback)
1313
- Drag voices in XZ plane; click to mute, Shift+Click reseed, Alt+Click solo
14-
- Keyboard: R (reseed all), Space (pause), + / - (tempo)
15-
- Starts muted by default; press M to unmute the master bus
14+
- Keyboard: R (new sequence), Space (pause/resume), ArrowLeft/Right (tempo), ArrowUp/Down (volume)
15+
- Starts at a lower default volume; use ArrowUp to raise or ArrowDown to lower
1616
- Dynamic hint shows current BPM, paused, and muted state
1717
- Rich visuals: instanced voice markers with emissive pulses, ambient waves background, post bloom/tonemap/vignette; optional analyser-driven spectrum dots
1818
- Native front-end renders and plays synthesized audio; parity improving:
@@ -34,7 +34,7 @@
3434
Notes:
3535

3636
- WebGL fallback is intentionally avoided; WebGPU is required.
37-
- If audio does not start, click the Start overlay and press M to unmute the master bus.
37+
- If audio does not start, click the Start overlay.
3838

3939
### Run (Web)
4040

@@ -45,8 +45,8 @@ Notes:
4545

4646
Quick controls (browser):
4747

48-
- R: reseed all • Space: pause/resume • +/-: tempo • M: master mute
49-
- Click a voice to mute; Alt+Click to solo; Shift+Click to reseed a voice; drag to move in XZ
48+
- R: new sequence • Space: pause/resume • ArrowLeft/Right: tempo • ArrowUp/Down: volume
49+
- Click canvas: play a note; mouse position affects sound
5050

5151
### Pre-commit Check
5252

@@ -66,9 +66,8 @@ This repo is configured to deploy via Cloudflare Workers; headers (COOP/COEP/COR
6666
Controls in browser:
6767

6868
- Click Start to initialize audio (canvas click also works)
69-
- Drag a circle to move a voice in XZ plane (updates spatialization)
70-
- Click a voice: mute; Shift+Click: reseed; Alt+Click: solo
71-
- Keys: R (reseed all), Space (pause/resume), + / - (tempo), M (master mute)
69+
- Click canvas: play a note; mouse position affects sound
70+
- Keys: R (new sequence), Space (pause/resume), ArrowLeft/Right (tempo), ArrowUp/Down (volume)
7271
- Mouse position maps to master saturation and delay; moving the pointer leaves a “water-like” trailing swirl in visuals
7372

7473
Headless test:

crates/app-native/src/main.rs

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::sync::{Arc, Mutex};
22
use std::thread;
33
use std::time::{Duration, Instant};
44
use wgpu::util::DeviceExt;
5+
use winit::keyboard::{KeyCode, PhysicalKey};
56
use winit::{event::*, event_loop::EventLoop, window::WindowBuilder};
67

78
use app_core::{
@@ -416,7 +417,8 @@ fn main() {
416417
)));
417418

418419
// Start native audio output (synth driven by MusicEngine)
419-
let _audio_stream = start_audio_engine(Arc::clone(&shared_state), Arc::clone(&engine));
420+
let (_audio_stream, audio_state) =
421+
start_audio_engine(Arc::clone(&shared_state), Arc::clone(&engine)).expect("audio engine");
420422

421423
let event_loop = EventLoop::new().expect("event loop");
422424
let window = WindowBuilder::new()
@@ -447,6 +449,43 @@ fn main() {
447449
event: WindowEvent::CloseRequested,
448450
..
449451
} => elwt.exit(),
452+
Event::WindowEvent {
453+
event:
454+
WindowEvent::KeyboardInput {
455+
event:
456+
KeyEvent {
457+
state: ElementState::Pressed,
458+
repeat: false,
459+
physical_key: PhysicalKey::Code(code),
460+
..
461+
},
462+
..
463+
},
464+
..
465+
} => {
466+
let mut vol_changed = false;
467+
{
468+
let mut st = audio_state.lock().unwrap();
469+
let step = 0.05f32;
470+
match code {
471+
KeyCode::ArrowUp | KeyCode::Equal | KeyCode::NumpadAdd => {
472+
st.master_volume = (st.master_volume + step).clamp(0.0, 1.0);
473+
vol_changed = true;
474+
}
475+
KeyCode::ArrowDown | KeyCode::Minus | KeyCode::NumpadSubtract => {
476+
st.master_volume = (st.master_volume - step).clamp(0.0, 1.0);
477+
vol_changed = true;
478+
}
479+
_ => {}
480+
}
481+
}
482+
if vol_changed {
483+
// Optionally log new volume for visibility
484+
if let Ok(st) = audio_state.lock() {
485+
log::info!("[keys] volume={:.2}", st.master_volume);
486+
}
487+
}
488+
}
450489
Event::WindowEvent {
451490
event: WindowEvent::CursorMoved { position, .. },
452491
..
@@ -556,6 +595,8 @@ struct ActiveOscillator {
556595
struct AudioState {
557596
sample_rate: f32,
558597
oscillators: Vec<ActiveOscillator>,
598+
// Master output volume scalar (0.0..=1.0). Applied after saturation.
599+
master_volume: f32,
559600
}
560601

561602
fn compute_equal_power_gains(pos_x_engine: f32) -> (f32, f32) {
@@ -569,7 +610,7 @@ fn compute_equal_power_gains(pos_x_engine: f32) -> (f32, f32) {
569610
fn start_audio_engine(
570611
shared_vis: Arc<Mutex<VisState>>,
571612
shared_engine: Arc<Mutex<MusicEngine>>,
572-
) -> Option<cpal::Stream> {
613+
) -> Option<(cpal::Stream, Arc<Mutex<AudioState>>)> {
573614
let host = cpal::default_host();
574615
let device = host.default_output_device()?;
575616
let config = device.default_output_config().ok()?;
@@ -579,6 +620,7 @@ fn start_audio_engine(
579620
let state = Arc::new(Mutex::new(AudioState {
580621
sample_rate,
581622
oscillators: Vec::new(),
623+
master_volume: 0.5, // start at half loudness
582624
}));
583625

584626
// Scheduler thread producing notes using MusicEngine
@@ -691,7 +733,7 @@ fn start_audio_engine(
691733
};
692734

693735
stream.play().ok()?;
694-
Some(stream)
736+
Some((stream, state))
695737
}
696738

697739
fn render_wave_sample(phase: f32, wave: WaveKind) -> f32 {
@@ -784,11 +826,14 @@ fn build_stream_f32(
784826
config,
785827
move |data: &mut [f32], _| {
786828
let mut guard = state.lock().unwrap();
829+
let vol = guard.master_volume.clamp(0.0, 1.0);
787830
let oscillators = &mut guard.oscillators;
788831
let mut frame = 0usize;
789832
while frame < data.len() {
790833
let (l_raw, r_raw) = mix_sample_stereo(oscillators);
791834
let (l, r) = apply_master_saturation(l_raw, r_raw);
835+
let l = l * vol;
836+
let r = r * vol;
792837
if channels >= 2 {
793838
if frame < data.len() {
794839
data[frame] = l;
@@ -818,13 +863,14 @@ fn build_stream_i16(
818863
config,
819864
move |data: &mut [i16], _| {
820865
let mut guard = state.lock().unwrap();
866+
let vol = guard.master_volume.clamp(0.0, 1.0);
821867
let oscillators = &mut guard.oscillators;
822868
let mut frame = 0usize;
823869
while frame < data.len() {
824870
let (l_raw, r_raw) = mix_sample_stereo(oscillators);
825871
let (l, r) = apply_master_saturation(l_raw, r_raw);
826-
let vl = (l.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
827-
let vr = (r.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
872+
let vl = ((l * vol).clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
873+
let vr = ((r * vol).clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
828874
if channels >= 2 {
829875
if frame < data.len() {
830876
data[frame] = vl;
@@ -854,13 +900,14 @@ fn build_stream_u16(
854900
config,
855901
move |data: &mut [u16], _| {
856902
let mut guard = state.lock().unwrap();
903+
let vol = guard.master_volume.clamp(0.0, 1.0);
857904
let oscillators = &mut guard.oscillators;
858905
let mut frame = 0usize;
859906
while frame < data.len() {
860907
let (l_raw, r_raw) = mix_sample_stereo(oscillators);
861908
let (l, r) = apply_master_saturation(l_raw, r_raw);
862-
let vl = (((l * 0.5 + 0.5).clamp(0.0, 1.0)) * u16::MAX as f32) as u16;
863-
let vr = (((r * 0.5 + 0.5).clamp(0.0, 1.0)) * u16::MAX as f32) as u16;
909+
let vl = ((((l * vol) * 0.5 + 0.5).clamp(0.0, 1.0)) * u16::MAX as f32) as u16;
910+
let vr = ((((r * vol) * 0.5 + 0.5).clamp(0.0, 1.0)) * u16::MAX as f32) as u16;
864911
if channels >= 2 {
865912
if frame < data.len() {
866913
data[frame] = vl;

crates/app-web/index.html

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@
3535
backdrop-filter: blur(2px);
3636
z-index: 10;
3737
}
38+
#start-content {
39+
display: flex;
40+
flex-direction: column;
41+
align-items: center;
42+
gap: 12px;
43+
text-align: center;
44+
color: #cfe7ff;
45+
}
46+
#start-keys {
47+
color: #9bb;
48+
font: 12px/1.4 monospace;
49+
max-width: 560px;
50+
background: rgba(20, 20, 30, 0.55);
51+
padding: 10px 12px;
52+
border: 1px solid rgba(80, 110, 150, 0.35);
53+
border-radius: 8px;
54+
}
3855
#start-btn {
3956
appearance: none;
4057
border: 1px solid #3a4b66;
@@ -76,13 +93,22 @@
7693
</head>
7794
<body>
7895
<div id="start-overlay">
79-
<button id="start-btn">Start</button>
96+
<div id="start-content">
97+
<button id="start-btn">Start</button>
98+
<div id="start-keys">
99+
Click Start to begin<br />
100+
Click canvas: play a note • Mouse position affects sound<br />
101+
Keys: R (new sequence) • Space (pause/resume) • ArrowLeft/Right
102+
(tempo) • ArrowUp/Down (volume) • F (fullscreen) • Esc (exit)
103+
</div>
104+
</div>
80105
</div>
81106
<canvas id="app-canvas" width="1280" height="720"></canvas>
82107
<div class="hint" data-visible="0" style="display: none">
83-
Click Start to begin • Drag to move a voice<br />
84-
Click: mute • Shift+Click: reseed • Alt+Click: solo<br />
85-
R: reseed all • Space: pause/resume • +/-: tempo • F: fullscreen<br />
108+
Click Start to begin<br />
109+
Click canvas: play a note • Mouse position affects sound<br />
110+
R: new sequence • Space: pause/resume • ArrowLeft/Right: tempo • F:
111+
fullscreen<br />
86112
BPM: 110 • Paused: no
87113
</div>
88114
<div

crates/app-web/src/lib.rs

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -49,22 +49,7 @@ async fn init() -> anyhow::Result<()> {
4949
.dyn_into::<web::HtmlCanvasElement>()
5050
.map_err(|e| anyhow::anyhow!(format!("{:?}", e)))?;
5151

52-
// Minimal early keyboard handler for hint toggle (works even if WebGPU init fails in CI)
53-
{
54-
let window = web::window().unwrap();
55-
let document = document.clone();
56-
let closure = Closure::wrap(Box::new(move |ev: web::KeyboardEvent| {
57-
let key = ev.key();
58-
if key == "h" || key == "H" {
59-
ui::toggle_hint_visibility(&document);
60-
ev.prevent_default();
61-
}
62-
}) as Box<dyn FnMut(_)>);
63-
window
64-
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())
65-
.ok();
66-
closure.forget();
67-
}
52+
// Removed 'h' help toggle; key mapping is shown on the Start overlay instead.
6853

6954
// Note: we will query the optional hint element lazily inside event handlers to avoid
7055
// capturing it here and forcing closures to be FnOnce.
@@ -190,8 +175,8 @@ async fn init() -> anyhow::Result<()> {
190175
return;
191176
}
192177
};
193-
// Start unmuted by default (removed M-key toggle)
194-
master_gain.gain().set_value(0.8);
178+
// Start at lower loudness by default (user can raise with ArrowUp)
179+
master_gain.gain().set_value(0.4);
195180
// Subtle master saturation (arctan) with wet/dry mix
196181
let sat_pre = match web::GainNode::new(&audio_ctx) {
197182
Ok(g) => g,
@@ -498,12 +483,8 @@ async fn init() -> anyhow::Result<()> {
498483
ms.y = pos.y;
499484
// noisy move debug log removed
500485
// Compute hover or drag update
501-
let (ro, rd) = render::screen_to_world_ray(
502-
&canvas_mouse,
503-
pos.x,
504-
pos.y,
505-
CAMERA_Z,
506-
);
486+
let (ro, rd) =
487+
render::screen_to_world_ray(&canvas_mouse, pos.x, pos.y, CAMERA_Z);
507488
let mut best = None::<(usize, f32)>;
508489
let spread = SPREAD;
509490
let z_offset = z_offset_vec3();
@@ -571,11 +552,12 @@ async fn init() -> anyhow::Result<()> {
571552
closure.forget();
572553
}
573554

574-
// Keyboard controls: R reseed all, Space pause, +/- bpm adjust, F/Escape fullscreen
555+
// Keyboard controls: R reseed all, Space pause, +/- bpm adjust, ArrowUp/Down volume, F/Escape fullscreen
575556
{
576557
let engine_k = engine.clone();
577558
let paused_k = paused.clone();
578559
let canvas_k = canvas_for_click_inner.clone();
560+
let master_gain_k = master_gain.clone();
579561
let window = web::window().unwrap();
580562
let closure = Closure::wrap(Box::new(move |ev: web::KeyboardEvent| {
581563
let key = ev.key();
@@ -618,8 +600,8 @@ async fn init() -> anyhow::Result<()> {
618600
}
619601
ev.prevent_default();
620602
}
621-
// Increase BPM
622-
"+" | "=" => {
603+
// Increase BPM (ArrowRight or +/=)
604+
"ArrowRight" | "+" | "=" => {
623605
let mut eng = engine_k.borrow_mut();
624606
let new_bpm = (eng.params.bpm + 5.0).min(240.0);
625607
eng.set_bpm(new_bpm);
@@ -636,7 +618,7 @@ async fn init() -> anyhow::Result<()> {
636618
el.dyn_ref::<web::HtmlElement>()
637619
{
638620
let content = format!(
639-
"Click Start to begin • Drag to move a voice\nClick: mute • Shift+Click: reseed • Alt+Click: solo\nR: reseed all • Space: pause/resume • +/-: tempo\nBPM: {:.0} • Paused: {}",
621+
"Click Start to begin\nClick canvas: play a note • Mouse affects sound\nR: new sequence • Space: pause/resume • ArrowLeft/Right: tempo\nBPM: {:.0} • Paused: {}",
640622
new_bpm,
641623
if paused_now { "yes" } else { "no" }
642624
);
@@ -647,8 +629,8 @@ async fn init() -> anyhow::Result<()> {
647629
}
648630
}
649631
}
650-
// Decrease BPM
651-
"-" | "_" => {
632+
// Decrease BPM (ArrowLeft or -/_)
633+
"ArrowLeft" | "-" | "_" => {
652634
let mut eng = engine_k.borrow_mut();
653635
let new_bpm = (eng.params.bpm - 5.0).max(40.0);
654636
eng.set_bpm(new_bpm);
@@ -665,7 +647,7 @@ async fn init() -> anyhow::Result<()> {
665647
el.dyn_ref::<web::HtmlElement>()
666648
{
667649
let content = format!(
668-
"Click Start to begin • Drag to move a voice\nClick: mute • Shift+Click: reseed • Alt+Click: solo\nR: reseed all • Space: pause/resume • +/-: tempo\nBPM: {:.0} • Paused: {}",
650+
"Click Start to begin\nClick canvas: play a note • Mouse affects sound\nR: new sequence • Space: pause/resume • ArrowLeft/Right: tempo\nBPM: {:.0} • Paused: {}",
669651
new_bpm,
670652
if paused_now { "yes" } else { "no" }
671653
);
@@ -700,6 +682,22 @@ async fn init() -> anyhow::Result<()> {
700682
}
701683
_ => {}
702684
}
685+
// Master volume on arrow keys (after other handlers so prevent_default only for arrows)
686+
match key.as_str() {
687+
"ArrowUp" => {
688+
let v = master_gain_k.gain().value();
689+
let nv = (v + 0.05).min(1.0);
690+
let _ = master_gain_k.gain().set_value(nv);
691+
ev.prevent_default();
692+
}
693+
"ArrowDown" => {
694+
let v = master_gain_k.gain().value();
695+
let nv = (v - 0.05).max(0.0);
696+
let _ = master_gain_k.gain().set_value(nv);
697+
ev.prevent_default();
698+
}
699+
_ => {}
700+
}
703701
})
704702
as Box<dyn FnMut(_)>);
705703
window

0 commit comments

Comments
 (0)