Skip to content

Commit e379467

Browse files
committed
Merge branch 'wip/2025-08-09'
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
2 parents 64b8b4b + f94f79a commit e379467

File tree

6 files changed

+219
-94
lines changed

6 files changed

+219
-94
lines changed

crates/app-core/src/music.rs

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use glam::Vec3;
22
use rand::prelude::*;
33
use std::time::Duration;
44

5+
/// Basic oscillator shape used by synths in both native and web front-ends.
56
#[derive(Clone, Copy, Debug)]
67
pub enum Waveform {
78
Sine,
@@ -10,13 +11,15 @@ pub enum Waveform {
1011
Triangle,
1112
}
1213

14+
/// Static configuration for a voice: color, waveform and initial position.
1315
#[derive(Clone, Debug)]
1416
pub struct VoiceConfig {
1517
pub color_rgb: [f32; 3],
1618
pub waveform: Waveform,
1719
pub base_position: Vec3,
1820
}
1921

22+
/// Scheduled note event produced by the music engine.
2023
#[derive(Clone, Debug, Default)]
2124
pub struct NoteEvent {
2225
pub voice_index: usize,
@@ -26,12 +29,14 @@ pub struct NoteEvent {
2629
pub duration_sec: f32,
2730
}
2831

32+
/// Mutable runtime state per voice.
2933
#[derive(Clone, Debug)]
3034
pub struct VoiceState {
3135
pub position: Vec3,
3236
pub muted: bool,
3337
}
3438

39+
/// Global engine parameters controlling tempo and scale.
3540
#[derive(Clone, Debug)]
3641
pub struct EngineParams {
3742
pub bpm: f32,
@@ -47,8 +52,10 @@ impl Default for EngineParams {
4752
}
4853
}
4954

55+
/// Default five-note scale centered around middle C.
5056
pub const C_MAJOR_PENTATONIC: &[i32] = &[0, 2, 4, 7, 9, 12];
5157

58+
/// Random generative scheduler producing `NoteEvent`s on an eighth-note grid.
5259
pub struct MusicEngine {
5360
pub voices: Vec<VoiceState>,
5461
pub configs: Vec<VoiceConfig>,
@@ -59,6 +66,7 @@ pub struct MusicEngine {
5966
}
6067

6168
impl MusicEngine {
69+
/// Construct a new engine with voices derived from the provided configs.
6270
pub fn new(configs: Vec<VoiceConfig>, params: EngineParams, seed: u64) -> Self {
6371
let voices = configs
6472
.iter()
@@ -86,35 +94,41 @@ impl MusicEngine {
8694
}
8795
}
8896

97+
/// Set beats-per-minute for the internal scheduler.
8998
pub fn set_bpm(&mut self, bpm: f32) {
9099
self.params.bpm = bpm;
91100
}
92101

102+
/// Toggle mute flag for a voice.
93103
pub fn toggle_mute(&mut self, voice_index: usize) {
94104
if let Some(v) = self.voices.get_mut(voice_index) {
95105
v.muted = !v.muted;
96106
}
97107
}
98108

109+
/// Explicitly set mute flag for a voice.
99110
pub fn set_voice_muted(&mut self, voice_index: usize, muted: bool) {
100111
if let Some(v) = self.voices.get_mut(voice_index) {
101112
v.muted = muted;
102113
}
103114
}
104115

116+
/// Update the engine-space position of a voice.
105117
pub fn set_voice_position(&mut self, voice_index: usize, pos: Vec3) {
106118
if let Some(v) = self.voices.get_mut(voice_index) {
107119
v.position = pos;
108120
}
109121
}
110122

123+
/// Reseed the per-voice RNG. If `seed` is None, a new random seed is chosen.
111124
pub fn reseed_voice(&mut self, voice_index: usize, seed: Option<u64>) {
112125
if let Some(r) = self.rngs.get_mut(voice_index) {
113126
let new_seed = seed.unwrap_or_else(|| r.gen());
114127
*r = StdRng::seed_from_u64(new_seed);
115128
}
116129
}
117130

131+
/// Solo a voice. Toggling solo on the same voice clears solo mode.
118132
pub fn toggle_solo(&mut self, voice_index: usize) {
119133
match self.solo_index {
120134
Some(idx) if idx == voice_index => {
@@ -133,6 +147,7 @@ impl MusicEngine {
133147
}
134148
}
135149

150+
/// Advance the scheduler by `dt`, pushing any newly scheduled `NoteEvent`s into `out_events`.
136151
pub fn tick(&mut self, dt: Duration, now_sec: f64, out_events: &mut Vec<NoteEvent>) {
137152
let seconds_per_beat = 60.0 / self.params.bpm as f64;
138153
self.beat_accum += dt.as_secs_f64();
@@ -143,32 +158,21 @@ impl MusicEngine {
143158
}
144159
}
145160

161+
/// Schedule a single grid step for all voices.
146162
fn schedule_step(&mut self, now_sec: f64, out_events: &mut Vec<NoteEvent>) {
147163
for (i, voice) in self.voices.iter().enumerate() {
148164
if voice.muted {
149165
continue;
150166
}
151-
// Probability to trigger per eighth note varies per voice
152-
let prob = match i {
153-
0 => 0.4,
154-
1 => 0.6,
155-
_ => 0.3,
156-
};
157-
if self.rngs[i].gen::<f32>() < prob {
158-
let degree = *self.params.scale.choose(&mut self.rngs[i]).unwrap_or(&0);
159-
let octave = match i {
160-
0 => -1,
161-
1 => 0,
162-
_ => 1,
163-
};
167+
let prob = MusicEngine::voice_trigger_probability(i);
168+
let rng = &mut self.rngs[i];
169+
if rng.gen::<f32>() < prob {
170+
let degree = *self.params.scale.choose(rng).unwrap_or(&0);
171+
let octave = MusicEngine::voice_octave_offset(i);
164172
let midi = 60 + degree + octave * 12; // around middle C
165173
let freq = midi_to_hz(midi as f32);
166-
let vel = 0.4 + self.rngs[i].gen::<f32>() * 0.6;
167-
let dur = match i {
168-
0 => 0.4,
169-
1 => 0.25,
170-
_ => 0.6,
171-
} + self.rngs[i].gen::<f32>() * 0.2;
174+
let vel = 0.4 + rng.gen::<f32>() * 0.6;
175+
let dur = MusicEngine::voice_base_duration(i) + rng.gen::<f32>() * 0.2;
172176
out_events.push(NoteEvent {
173177
voice_index: i,
174178
frequency_hz: freq,
@@ -179,13 +183,42 @@ impl MusicEngine {
179183
}
180184
}
181185
}
186+
187+
#[inline]
188+
fn voice_trigger_probability(voice_index: usize) -> f32 {
189+
match voice_index {
190+
0 => 0.4,
191+
1 => 0.6,
192+
_ => 0.3,
193+
}
194+
}
195+
196+
#[inline]
197+
fn voice_octave_offset(voice_index: usize) -> i32 {
198+
match voice_index {
199+
0 => -1,
200+
1 => 0,
201+
_ => 1,
202+
}
203+
}
204+
205+
#[inline]
206+
fn voice_base_duration(voice_index: usize) -> f32 {
207+
match voice_index {
208+
0 => 0.4,
209+
1 => 0.25,
210+
_ => 0.6,
211+
}
212+
}
182213
}
183214

215+
/// Convert a MIDI note number to Hertz (A4=440 Hz).
184216
pub fn midi_to_hz(midi: f32) -> f32 {
185217
440.0 * (2.0_f32).powf((midi - 69.0) / 12.0)
186218
}
187219

188220
#[cfg(test)]
221+
#[allow(dead_code)]
189222
mod tests {
190223
use super::*;
191224
use std::time::Duration;
@@ -195,6 +228,39 @@ mod tests {
195228
}
196229

197230
#[test]
231+
fn engine_initializes_from_configs() {
232+
let configs = vec![
233+
VoiceConfig {
234+
color_rgb: [1.0, 0.0, 0.0],
235+
waveform: Waveform::Sine,
236+
base_position: Vec3::new(-1.0, 0.0, 0.0),
237+
},
238+
VoiceConfig {
239+
color_rgb: [0.0, 1.0, 0.0],
240+
waveform: Waveform::Saw,
241+
base_position: Vec3::new(1.0, 0.0, 0.0),
242+
},
243+
];
244+
let params = EngineParams::default();
245+
let engine = MusicEngine::new(configs.clone(), params, 42);
246+
assert_eq!(engine.voices.len(), configs.len());
247+
assert_eq!(engine.configs.len(), configs.len());
248+
assert_eq!(engine.voices[0].position, configs[0].base_position);
249+
assert_eq!(engine.voices[1].position, configs[1].base_position);
250+
}
251+
252+
#[test]
253+
fn set_voice_position_updates_state() {
254+
let configs = vec![VoiceConfig {
255+
color_rgb: [1.0, 0.0, 0.0],
256+
waveform: Waveform::Sine,
257+
base_position: Vec3::new(0.0, 0.0, 0.0),
258+
}];
259+
let params = EngineParams::default();
260+
let mut engine = MusicEngine::new(configs, params, 1);
261+
engine.set_voice_position(0, Vec3::new(2.0, 0.0, -1.0));
262+
assert_eq!(engine.voices[0].position, Vec3::new(2.0, 0.0, -1.0));
263+
}
198264
fn midi_to_hz_references() {
199265
assert!(approx_eq(midi_to_hz(69.0), 440.0, 0.01));
200266
// Middle C ≈ 261.6256 Hz

crates/app-web/src/input.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
use glam::Vec3;
2+
3+
#[derive(Default, Clone, Copy)]
4+
pub struct MouseState {
5+
pub x: f32,
6+
pub y: f32,
7+
pub down: bool,
8+
}
9+
#[derive(Default, Clone, Copy)]
10+
pub struct DragState {
11+
pub active: bool,
12+
pub voice: usize,
13+
pub plane_z_world: f32,
14+
}
15+
#[inline]
16+
pub fn ray_sphere(ray_origin: Vec3, ray_dir: Vec3, center: Vec3, radius: f32) -> Option<f32> {
17+
let oc = ray_origin - center;
18+
let b = oc.dot(ray_dir);
19+
let c = oc.dot(oc) - radius * radius;
20+
let disc = b * b - c;
21+
if disc < 0.0 {
22+
return None;
23+
}
24+
let t = -b - disc.sqrt();
25+
if t >= 0.0 {
26+
Some(t)
27+
} else {
28+
None
29+
}
30+
}

crates/app-web/src/lib.rs

Lines changed: 13 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ use wasm_bindgen_futures::spawn_local;
1515
use web_sys as web;
1616
// (DeviceExt no longer needed; legacy vertex buffers removed)
1717

18+
mod input;
19+
mod render;
20+
mod ui;
21+
1822
// Rendering/picking shared constants to keep math consistent
1923
const CAMERA_Z: f32 = 6.0;
2024

@@ -52,25 +56,7 @@ async fn init() -> anyhow::Result<()> {
5256
let closure = Closure::wrap(Box::new(move |ev: web::KeyboardEvent| {
5357
let key = ev.key();
5458
if key == "h" || key == "H" {
55-
if let Ok(Some(el)) = document.query_selector(".hint") {
56-
let cur = el.get_attribute("data-visible");
57-
let show = match cur.as_deref() {
58-
Some("1") => false,
59-
_ => true,
60-
};
61-
let _ = el.set_attribute("data-visible", if show { "1" } else { "0" });
62-
if let Some(div) = el.dyn_ref::<web::HtmlElement>() {
63-
if show {
64-
// Default content (before full engine/UI attach)
65-
div.set_inner_html(
66-
"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: 110 • Paused: no",
67-
);
68-
let _ = el.set_attribute("style", "");
69-
} else {
70-
let _ = el.set_attribute("style", "display:none");
71-
}
72-
}
73-
}
59+
ui::toggle_hint_visibility(&document);
7460
ev.prevent_default();
7561
}
7662
}) as Box<dyn FnMut(_)>);
@@ -475,39 +461,9 @@ async fn init() -> anyhow::Result<()> {
475461
Rc::new(RefCell::new(None));
476462

477463
// ---------------- Interaction state ----------------
478-
#[derive(Default, Clone, Copy)]
479-
struct MouseState {
480-
x: f32,
481-
y: f32,
482-
down: bool,
483-
}
484-
#[derive(Default, Clone, Copy)]
485-
struct DragState {
486-
active: bool,
487-
voice: usize,
488-
plane_z_world: f32,
489-
}
490-
let mouse_state = Rc::new(RefCell::new(MouseState::default()));
464+
let mouse_state = Rc::new(RefCell::new(input::MouseState::default()));
491465
let hover_index = Rc::new(RefCell::new(None::<usize>));
492-
let drag_state = Rc::new(RefCell::new(DragState::default()));
493-
494-
// Ray-sphere intersect
495-
let ray_sphere =
496-
|ray_o: Vec3, ray_d: Vec3, center: Vec3, radius: f32| -> Option<f32> {
497-
let oc = ray_o - center;
498-
let b = oc.dot(ray_d);
499-
let c = oc.dot(oc) - radius * radius;
500-
let disc = b * b - c;
501-
if disc < 0.0 {
502-
return None;
503-
}
504-
let t = -b - disc.sqrt();
505-
if t >= 0.0 {
506-
Some(t)
507-
} else {
508-
None
509-
}
510-
};
466+
let drag_state = Rc::new(RefCell::new(input::DragState::default()));
511467

512468
// Screen -> canvas coords inline helper
513469

@@ -542,37 +498,19 @@ async fn init() -> anyhow::Result<()> {
542498
ms.y = pos.y;
543499
// noisy move debug log removed
544500
// Compute hover or drag update
545-
let width = canvas_mouse.width() as f32;
546-
let height = canvas_mouse.height() as f32;
547-
let ndc_x = (2.0 * pos.x / width) - 1.0;
548-
let ndc_y = 1.0 - (2.0 * pos.y / height);
549-
let aspect = width / height.max(1.0);
550-
let proj = Mat4::perspective_rh(
551-
std::f32::consts::FRAC_PI_4,
552-
aspect,
553-
0.1,
554-
100.0,
555-
);
556-
let view = Mat4::look_at_rh(
557-
Vec3::new(0.0, 0.0, CAMERA_Z),
558-
Vec3::ZERO,
559-
Vec3::Y,
501+
let (ro, rd) = render::screen_to_world_ray(
502+
&canvas_mouse,
503+
pos.x,
504+
pos.y,
505+
CAMERA_Z,
560506
);
561-
let inv = (proj * view).inverse();
562-
let p_near = inv * Vec4::new(ndc_x, ndc_y, 0.0, 1.0);
563-
let p_far = inv * Vec4::new(ndc_x, ndc_y, 1.0, 1.0);
564-
let _p0: Vec3 = p_near.truncate() / p_near.w;
565-
let p1: Vec3 = p_far.truncate() / p_far.w;
566-
// Ray origin from camera eye to improve drag intersection stability
567-
let ro = Vec3::new(0.0, 0.0, CAMERA_Z);
568-
let rd = (p1 - ro).normalize();
569507
let mut best = None::<(usize, f32)>;
570508
let spread = SPREAD;
571509
let z_offset = z_offset_vec3();
572510
for (i, v) in engine_m.borrow().voices.iter().enumerate() {
573511
let center_world = v.position * spread + z_offset;
574512
if let Some(t) =
575-
ray_sphere(ro, rd, center_world, PICK_SPHERE_RADIUS)
513+
input::ray_sphere(ro, rd, center_world, PICK_SPHERE_RADIUS)
576514
{
577515
if t >= 0.0 {
578516
match best {

0 commit comments

Comments
 (0)