Skip to content

Commit d5a9b12

Browse files
committed
feat(core): microtonal detune support + hint overlay updates; fix borrow panics in browser by reducing RefCell lifetimes and snapshotting state; add logs for keys/clicks to satisfy headless test; update tests for detune accuracy
1 parent cb956cf commit d5a9b12

File tree

8 files changed

+333
-4
lines changed

8 files changed

+333
-4
lines changed

index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ <h3>Keys</h3>
170170
<li><span class="kbd">R</span>: new sequence</li>
171171
<li><span class="kbd">T</span>: random root + mode</li>
172172
<li><span class="kbd">Space</span>: pause/resume</li>
173+
<li><span class="kbd">,</span>/<span class="kbd">.</span>: detune ±50¢ (Shift for ±10¢)</li>
174+
<li><span class="kbd">/</span>: reset detune to 0¢</li>
173175
<li>
174176
<span class="kbd">Enter</span>/<span class="kbd">Esc</span>:
175177
full/exit screen
@@ -215,6 +217,16 @@ <h3>Modes (1..7)</h3>
215217
</div>
216218
</div>
217219
<canvas id="app-canvas" width="1280" height="720"></canvas>
220+
<div
221+
id="hint-overlay"
222+
style="
223+
position: fixed;
224+
left: 12px;
225+
top: 12px;
226+
z-index: 5;
227+
display: none;
228+
"
229+
></div>
218230
<div
219231
id="audio-error"
220232
style="

src/core/music.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,13 @@ pub struct VoiceState {
5757
/// - `bpm` controls the tempo of the scheduler (beats per minute)
5858
/// - `scale` is the allowed pitch degree set, expressed as semitone offsets
5959
/// - `root_midi` is the MIDI note number of the tonal center (e.g., 60 for C4)
60+
/// - `detune_cents` is the global detune offset in cents (-200 to +200)
6061
#[derive(Clone, Debug)]
6162
pub struct EngineParams {
6263
pub bpm: f32,
6364
pub scale: &'static [i32],
6465
pub root_midi: i32,
66+
pub detune_cents: f32,
6567
}
6668

6769
impl Default for EngineParams {
@@ -70,6 +72,7 @@ impl Default for EngineParams {
7072
bpm: 110.0,
7173
scale: C_MAJOR_PENTATONIC,
7274
root_midi: 60, // Middle C
75+
detune_cents: 0.0,
7376
}
7477
}
7578
}
@@ -141,6 +144,24 @@ impl MusicEngine {
141144
self.params.bpm = bpm;
142145
}
143146

147+
/// Set the global detune offset in cents.
148+
/// Range: -200 to +200 cents (±2 semitones)
149+
pub fn set_detune_cents(&mut self, detune_cents: f32) {
150+
self.params.detune_cents = detune_cents.clamp(-200.0, 200.0);
151+
}
152+
153+
/// Adjust the global detune offset by the specified amount in cents.
154+
/// The result is clamped to the valid range of -200 to +200 cents.
155+
pub fn adjust_detune_cents(&mut self, delta_cents: f32) {
156+
let new_detune = self.params.detune_cents + delta_cents;
157+
self.set_detune_cents(new_detune);
158+
}
159+
160+
/// Reset the global detune offset to 0 cents (no detune).
161+
pub fn reset_detune(&mut self) {
162+
self.params.detune_cents = 0.0;
163+
}
164+
144165
/// Toggle mute flag for a voice.
145166
pub fn toggle_mute(&mut self, voice_index: usize) {
146167
if let Some(v) = self.voices.get_mut(voice_index) {
@@ -205,7 +226,7 @@ impl MusicEngine {
205226
let degree = *self.params.scale.choose(rng).unwrap_or(&0);
206227
let octave = self.configs[i].octave_offset;
207228
let midi = self.params.root_midi + degree + octave * 12;
208-
let freq = midi_to_hz(midi as f32);
229+
let freq = midi_to_hz_with_detune(midi as f32, self.params.detune_cents);
209230
let vel = 0.4 + rng.gen::<f32>() * 0.6;
210231
let dur = self.configs[i].base_duration + rng.gen::<f32>() * 0.2;
211232
out_events.push(NoteEvent {
@@ -222,6 +243,20 @@ impl MusicEngine {
222243
/// Convert a MIDI note number to Hertz (A4=440 Hz).
223244
///
224245
/// Monotonic and exhibits octave symmetry: +12 semitones doubles the frequency.
246+
/// Supports fractional MIDI values for microtonal precision.
225247
pub fn midi_to_hz(midi: f32) -> f32 {
226248
440.0 * (2.0_f32).powf((midi - 69.0) / 12.0)
227249
}
250+
251+
/// Convert a MIDI note number to Hertz with detune offset in cents.
252+
///
253+
/// The detune_cents parameter allows for microtonal adjustments:
254+
/// - Positive values raise the pitch (e.g., +50¢ = quarter tone sharp)
255+
/// - Negative values lower the pitch (e.g., -50¢ = quarter tone flat)
256+
/// - Range: -200 to +200 cents (±2 semitones)
257+
pub fn midi_to_hz_with_detune(midi: f32, detune_cents: f32) -> f32 {
258+
let clamped_detune = detune_cents.clamp(-200.0, 200.0);
259+
let detune_semitones = clamped_detune / 100.0;
260+
let adjusted_midi = midi + detune_semitones;
261+
midi_to_hz(adjusted_midi)
262+
}

src/events/keyboard.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,43 @@
11
use crate::core::MusicEngine;
22
use crate::core::{AEOLIAN, DORIAN, IONIAN, LOCRIAN, LYDIAN, MIXOLYDIAN, PHRYGIAN};
3+
use crate::overlay;
34
use std::cell::RefCell;
45
use std::rc::Rc;
56
use wasm_bindgen::JsCast;
67
use web_sys as web;
78

9+
/// Get the name of the current scale for display purposes
10+
fn get_scale_name(scale: &[i32]) -> &'static str {
11+
match scale {
12+
s if s == IONIAN => "Ionian (major)",
13+
s if s == DORIAN => "Dorian",
14+
s if s == PHRYGIAN => "Phrygian",
15+
s if s == LYDIAN => "Lydian",
16+
s if s == MIXOLYDIAN => "Mixolydian",
17+
s if s == AEOLIAN => "Aeolian (minor)",
18+
s if s == LOCRIAN => "Locrian",
19+
_ => "Custom",
20+
}
21+
}
22+
23+
/// Update the hint overlay after engine parameter changes
24+
fn update_hint_after_change(engine: &Rc<RefCell<MusicEngine>>) {
25+
if let Some(window) = web::window() {
26+
if let Some(document) = window.document() {
27+
let (detune, bpm, scale_name) = {
28+
let eng = engine.borrow();
29+
(
30+
eng.params.detune_cents,
31+
eng.params.bpm,
32+
get_scale_name(eng.params.scale),
33+
)
34+
};
35+
overlay::update_hint(&document, detune, bpm, scale_name);
36+
overlay::show_hint(&document);
37+
}
38+
}
39+
}
40+
841
#[inline]
942
pub fn root_midi_for_key(key: &str) -> Option<i32> {
1043
match key {
@@ -43,10 +76,12 @@ pub fn handle_global_keydown(
4376
let key = ev.key();
4477
if let Some(midi) = root_midi_for_key(&key) {
4578
engine.borrow_mut().params.root_midi = midi;
79+
update_hint_after_change(engine);
4680
return;
4781
}
4882
if let Some(scale) = mode_scale_for_digit(&key) {
4983
engine.borrow_mut().params.scale = scale;
84+
update_hint_after_change(engine);
5085
return;
5186
}
5287
match key.as_str() {
@@ -56,6 +91,7 @@ pub fn handle_global_keydown(
5691
for i in 0..voice_len {
5792
eng.reseed_voice(i, None);
5893
}
94+
log::info!("[keys] reseeded all voices");
5995
}
6096
"t" | "T" => {
6197
let roots: [i32; 7] = [60, 62, 64, 65, 67, 69, 71]; // C, D, E, F, G, A, B
@@ -67,21 +103,54 @@ pub fn handle_global_keydown(
67103
let mut eng = engine.borrow_mut();
68104
eng.params.root_midi = roots[ri];
69105
eng.params.scale = modes[mi];
106+
drop(eng);
107+
update_hint_after_change(engine);
70108
}
71109
" " => {
72110
let mut p = paused.borrow_mut();
73111
*p = !*p;
112+
log::info!("[keys] paused={}", *p);
74113
ev.prevent_default();
75114
}
76115
"ArrowRight" | "+" | "=" => {
77116
let mut eng = engine.borrow_mut();
78117
let new_bpm = (eng.params.bpm + 5.0).min(240.0);
79118
eng.set_bpm(new_bpm);
119+
drop(eng);
120+
update_hint_after_change(engine);
80121
}
81122
"ArrowLeft" | "-" | "_" => {
82123
let mut eng = engine.borrow_mut();
83124
let new_bpm = (eng.params.bpm - 5.0).max(40.0);
84125
eng.set_bpm(new_bpm);
126+
drop(eng);
127+
update_hint_after_change(engine);
128+
}
129+
"," => {
130+
let mut eng = engine.borrow_mut();
131+
if ev.shift_key() {
132+
eng.adjust_detune_cents(-10.0); // Fine adjustment
133+
} else {
134+
eng.adjust_detune_cents(-50.0); // Coarse adjustment
135+
}
136+
drop(eng);
137+
update_hint_after_change(engine);
138+
}
139+
"." => {
140+
let mut eng = engine.borrow_mut();
141+
if ev.shift_key() {
142+
eng.adjust_detune_cents(10.0); // Fine adjustment
143+
} else {
144+
eng.adjust_detune_cents(50.0); // Coarse adjustment
145+
}
146+
drop(eng);
147+
update_hint_after_change(engine);
148+
}
149+
"/" => {
150+
let mut eng = engine.borrow_mut();
151+
eng.reset_detune();
152+
drop(eng);
153+
update_hint_after_change(engine);
85154
}
86155
"Enter" => {
87156
if let Some(win) = web::window() {

src/events/pointer.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ fn wire_pointermove(w: &InputWiring) {
4949
let mut best = None::<(usize, f32)>;
5050
let z_offset = Z_OFFSET;
5151

52-
for (i, v) in w.engine.borrow().voices.iter().enumerate() {
52+
let engine_snapshot = w.engine.borrow();
53+
for (i, v) in engine_snapshot.voices.iter().enumerate() {
5354
let center_world = v.position * SPREAD + z_offset;
5455

5556
if let Some(t) = input::ray_sphere(ro, rd, center_world, PICK_SPHERE_RADIUS) {
@@ -79,8 +80,8 @@ fn wire_pointermove(w: &InputWiring) {
7980
eng_pos.z *= scale;
8081
}
8182

82-
let mut eng = w.engine.borrow_mut();
8383
let vi = w.drag_state.borrow().voice;
84+
let mut eng = w.engine.borrow_mut();
8485
eng.set_voice_position(vi, glam::Vec3::new(eng_pos.x, 0.0, eng_pos.z));
8586
}
8687
}
@@ -137,10 +138,13 @@ fn wire_pointerup(w: &InputWiring) {
137138
let alt = ev.alt_key();
138139
if alt {
139140
w.engine.borrow_mut().toggle_solo(i);
141+
log::info!("[click] solo voice {}", i);
140142
} else if shift {
141143
w.engine.borrow_mut().reseed_voice(i, None);
144+
log::info!("[click] reseed voice {}", i);
142145
} else {
143146
w.engine.borrow_mut().toggle_mute(i);
147+
log::info!("[click] toggle mute voice {}", i);
144148
}
145149
} else {
146150
let [uvx, uvy] = input::pointer_canvas_uv(&ev, &w.canvas);

src/frame.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,12 @@ impl<'a> FrameContext<'a> {
9898
);
9999

100100
// Per-voice audio positioning and sends
101+
let voice_positions_snapshot: Vec<Vec3> = {
102+
let eng = self.engine.borrow();
103+
eng.voices.iter().map(|v| v.position).collect()
104+
};
101105
for i in 0..self.voice_panners.len() {
102-
let pos = self.engine.borrow().voices[i].position;
106+
let pos = voice_positions_snapshot[i];
103107
self.voice_panners[i].position_x().set_value(pos.x as f32);
104108
self.voice_panners[i].position_y().set_value(pos.y as f32);
105109
self.voice_panners[i].position_z().set_value(pos.z as f32);

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ async fn build_audio_and_engine(_document: web::Document) -> anyhow::Result<Init
7676
bpm: 110.0,
7777
scale: C_MAJOR_PENTATONIC,
7878
root_midi: 60,
79+
detune_cents: 0.0,
7980
},
8081
42,
8182
)));

src/overlay.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,32 @@ pub fn toggle(document: &web::Document) {
4242
hide(document);
4343
}
4444
}
45+
46+
/// Update the hint overlay with current engine state
47+
pub fn update_hint(document: &web::Document, detune_cents: f32, bpm: f32, scale_name: &str) {
48+
if let Some(el) = document.get_element_by_id("hint-overlay") {
49+
let detune_text = if detune_cents.abs() < 0.1 {
50+
"Detune: 0¢".to_string()
51+
} else {
52+
let sign = if detune_cents > 0.0 { "+" } else { "" };
53+
format!("Detune: {}{:.0}¢", sign, detune_cents)
54+
};
55+
56+
let bpm_text = format!("BPM: {:.0}", bpm);
57+
let scale_text = format!("Scale: {}", scale_name);
58+
59+
let hint_html = format!(
60+
"<div style='color: #cfe7ff; font: 13px system-ui; background: rgba(10, 14, 24, 0.8); padding: 8px 12px; border-radius: 6px; border: 1px solid rgba(80, 110, 150, 0.35);'>{} • {} • {}</div>",
61+
detune_text, bpm_text, scale_text
62+
);
63+
64+
el.set_inner_html(&hint_html);
65+
}
66+
}
67+
68+
/// Show the hint overlay
69+
pub fn show_hint(document: &web::Document) {
70+
if let Some(el) = document.get_element_by_id("hint-overlay") {
71+
el.set_attribute("style", "").ok();
72+
}
73+
}

0 commit comments

Comments
 (0)