Add a virtual keyboard to the visualiser
This commit is contained in:
@@ -46,6 +46,17 @@ impl AudioEngine {
|
||||
self.analyser.clone()
|
||||
}
|
||||
|
||||
/// Returns the underlying AudioContext so JS can create and schedule nodes.
|
||||
pub fn audio_context(&self) -> AudioContext {
|
||||
self.ctx.clone()
|
||||
}
|
||||
|
||||
/// Returns the main input GainNode. Connect synthesiser sources here so
|
||||
/// they pass through the analyser and reach the speakers.
|
||||
pub fn input_node(&self) -> GainNode {
|
||||
self.gain.clone()
|
||||
}
|
||||
|
||||
pub fn set_params(&self, _json: &str) -> Result<(), JsValue> {
|
||||
// TODO: parse JSON and post to AudioWorkletNode MessagePort
|
||||
Ok(())
|
||||
|
||||
252
crates/synth-visualiser/src/keyboard.rs
Normal file
252
crates/synth-visualiser/src/keyboard.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
//! Virtual piano keyboard rendered on a Canvas 2D context.
|
||||
//!
|
||||
//! Renders two octaves (C3–B4, MIDI 48–71). Pointer events return the MIDI
|
||||
//! note number that was hit, or -1 for none / note-off. The JS caller is
|
||||
//! responsible for actually synthesising sound via the Web Audio API.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::{HtmlCanvasElement, CanvasRenderingContext2d};
|
||||
|
||||
// ── Layout constants ──────────────────────────────────────────────────────────
|
||||
|
||||
/// First MIDI note shown (C3 = 48).
|
||||
const START_NOTE: u8 = 48;
|
||||
/// Number of octaves shown.
|
||||
const NUM_OCTAVES: usize = 2;
|
||||
/// White keys per octave.
|
||||
const WHITE_PER_OCT: usize = 7;
|
||||
/// Total white keys on the keyboard.
|
||||
const TOTAL_WHITE: usize = NUM_OCTAVES * WHITE_PER_OCT;
|
||||
|
||||
/// Semitone offset within an octave for each white key (C D E F G A B).
|
||||
const WHITE_SEMITONE: [u8; 7] = [0, 2, 4, 5, 7, 9, 11];
|
||||
|
||||
/// Black key info: (center x in white-key units, semitone within octave).
|
||||
/// The center x is the fraction of a white key's width from the left edge of
|
||||
/// the octave. These values give standard piano proportions.
|
||||
const BLACK_KEYS: [(f64, u8); 5] = [
|
||||
(0.67, 1), // C#
|
||||
(1.67, 3), // D#
|
||||
(3.67, 6), // F#
|
||||
(4.67, 8), // G#
|
||||
(5.67, 10), // A#
|
||||
];
|
||||
|
||||
/// Black key width as a fraction of white key width.
|
||||
const BK_W_RATIO: f64 = 0.60;
|
||||
/// Black key height as a fraction of the total canvas height.
|
||||
const BK_H_RATIO: f64 = 0.60;
|
||||
|
||||
// ── VirtualKeyboard ───────────────────────────────────────────────────────────
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct VirtualKeyboard {
|
||||
canvas: HtmlCanvasElement,
|
||||
ctx2d: CanvasRenderingContext2d,
|
||||
/// Currently pressed note (-1 = none). Set by pointer or external call.
|
||||
active_note: i32,
|
||||
/// Note under the pointer when not pressing (for hover highlight).
|
||||
hover_note: i32,
|
||||
/// True while a pointer button is held down over this canvas.
|
||||
pressing: bool,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl VirtualKeyboard {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(canvas_id: &str) -> Result<VirtualKeyboard, JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let canvas: HtmlCanvasElement = document
|
||||
.get_element_by_id(canvas_id)
|
||||
.ok_or_else(|| JsValue::from_str(&format!("canvas #{canvas_id} not found")))?
|
||||
.dyn_into()?;
|
||||
let ctx2d: CanvasRenderingContext2d = canvas
|
||||
.get_context("2d")?
|
||||
.ok_or("no 2d context")?
|
||||
.dyn_into()?;
|
||||
Ok(VirtualKeyboard {
|
||||
canvas, ctx2d,
|
||||
active_note: -1,
|
||||
hover_note: -1,
|
||||
pressing: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Pointer events ────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the MIDI note hit, or -1.
|
||||
pub fn on_pointer_down(&mut self, x: f32, y: f32) -> i32 {
|
||||
self.pressing = true;
|
||||
self.hover_note = -1;
|
||||
let note = self.hit_note(x as f64, y as f64);
|
||||
self.active_note = note;
|
||||
note
|
||||
}
|
||||
|
||||
/// While pressing: returns current note (or -1 for off-key), so the caller
|
||||
/// can glide the oscillator. While hovering: updates highlight, returns -2
|
||||
/// (caller should ignore for synthesis).
|
||||
pub fn on_pointer_move(&mut self, x: f32, y: f32) -> i32 {
|
||||
let note = self.hit_note(x as f64, y as f64);
|
||||
if self.pressing {
|
||||
self.active_note = note;
|
||||
note
|
||||
} else {
|
||||
self.hover_note = note;
|
||||
-2 // sentinel: hover-only, no synthesis change
|
||||
}
|
||||
}
|
||||
|
||||
/// Always returns -1 (note off).
|
||||
pub fn on_pointer_up(&mut self, _x: f32, _y: f32) -> i32 {
|
||||
self.pressing = false;
|
||||
self.active_note = -1;
|
||||
-1
|
||||
}
|
||||
|
||||
/// Called when the pointer leaves the canvas element. Returns -1 if a
|
||||
/// note was active (caller should stop synthesis).
|
||||
pub fn on_pointer_leave(&mut self) -> i32 {
|
||||
self.hover_note = -1;
|
||||
if self.pressing {
|
||||
self.pressing = false;
|
||||
self.active_note = -1;
|
||||
return -1;
|
||||
}
|
||||
-2 // was just hovering, no synthesis change
|
||||
}
|
||||
|
||||
// ── Computer-keyboard / external control ──────────────────────────────────
|
||||
|
||||
/// Set the active note from an external source (computer keyboard).
|
||||
/// Pass -1 to clear.
|
||||
pub fn set_active_note(&mut self, note: i32) {
|
||||
self.active_note = note;
|
||||
}
|
||||
|
||||
/// Returns the currently active MIDI note, or -1.
|
||||
pub fn active_note(&self) -> i32 {
|
||||
self.active_note
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(&self) {
|
||||
let cw = self.canvas.width() as f64;
|
||||
let ch = self.canvas.height() as f64;
|
||||
if cw < 1.0 || ch < 1.0 { return; }
|
||||
|
||||
let ctx = &self.ctx2d;
|
||||
let key_w = cw / TOTAL_WHITE as f64;
|
||||
let bk_w = key_w * BK_W_RATIO;
|
||||
let bk_h = ch * BK_H_RATIO;
|
||||
|
||||
// Background strip
|
||||
ctx.set_fill_style_str("#0d0d0d");
|
||||
ctx.fill_rect(0.0, 0.0, cw, ch);
|
||||
|
||||
// ── White keys ────────────────────────────────────────────────────────
|
||||
for i in 0..TOTAL_WHITE {
|
||||
let oct = i / WHITE_PER_OCT;
|
||||
let sem = WHITE_SEMITONE[i % WHITE_PER_OCT];
|
||||
let note = (START_NOTE + oct as u8 * 12 + sem) as i32;
|
||||
let x = i as f64 * key_w;
|
||||
|
||||
let color = if note == self.active_note {
|
||||
"#6ab4e8"
|
||||
} else if note == self.hover_note {
|
||||
"#e4e4e4"
|
||||
} else {
|
||||
"#d0d0d0"
|
||||
};
|
||||
|
||||
ctx.set_fill_style_str(color);
|
||||
ctx.fill_rect(x + 1.0, 0.0, key_w - 2.0, ch - 1.0);
|
||||
|
||||
// Subtle 3-D edge: bright top, darker side
|
||||
ctx.set_fill_style_str("rgba(255,255,255,0.4)");
|
||||
ctx.fill_rect(x + 1.0, 0.0, key_w - 2.0, 3.0);
|
||||
ctx.set_fill_style_str("rgba(0,0,0,0.15)");
|
||||
ctx.fill_rect(x + key_w - 3.0, 0.0, 2.0, ch - 1.0);
|
||||
|
||||
// C-note octave label at the bottom
|
||||
if sem == 0 {
|
||||
let oct_label = format!("C{}", START_NOTE / 12 + oct as u8);
|
||||
ctx.set_font("bold 9px sans-serif");
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_fill_style_str(if note == self.active_note { "#fff" } else { "#888" });
|
||||
let _ = ctx.fill_text(&oct_label, x + key_w * 0.5, ch - 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Black keys (drawn on top) ─────────────────────────────────────────
|
||||
for oct in 0..NUM_OCTAVES {
|
||||
let oct_x = oct as f64 * WHITE_PER_OCT as f64 * key_w;
|
||||
for &(offset, sem) in &BLACK_KEYS {
|
||||
let note = (START_NOTE + oct as u8 * 12 + sem) as i32;
|
||||
let bx = oct_x + offset * key_w - bk_w * 0.5;
|
||||
|
||||
let color = if note == self.active_note {
|
||||
"#3a8fc0"
|
||||
} else if note == self.hover_note {
|
||||
"#2a2a2a"
|
||||
} else {
|
||||
"#101010"
|
||||
};
|
||||
|
||||
ctx.set_fill_style_str(color);
|
||||
ctx.fill_rect(bx, 0.0, bk_w, bk_h);
|
||||
|
||||
// Subtle highlight on the top edge
|
||||
ctx.set_fill_style_str("rgba(255,255,255,0.10)");
|
||||
ctx.fill_rect(bx + 1.0, 0.0, bk_w - 2.0, 4.0);
|
||||
|
||||
// Bottom rounded look (darker strip)
|
||||
ctx.set_fill_style_str("rgba(0,0,0,0.5)");
|
||||
ctx.fill_rect(bx, bk_h - 6.0, bk_w, 6.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Thin border around the whole keyboard
|
||||
ctx.set_stroke_style_str("#333");
|
||||
ctx.set_line_width(1.0);
|
||||
ctx.stroke_rect(0.0, 0.0, cw, ch);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
impl VirtualKeyboard {
|
||||
fn key_w(&self) -> f64 { self.canvas.width() as f64 / TOTAL_WHITE as f64 }
|
||||
fn bk_w(&self) -> f64 { self.key_w() * BK_W_RATIO }
|
||||
fn bk_h(&self) -> f64 { self.canvas.height() as f64 * BK_H_RATIO }
|
||||
|
||||
/// Returns the MIDI note number at canvas position (x, y), or -1.
|
||||
fn hit_note(&self, x: f64, y: f64) -> i32 {
|
||||
let cw = self.canvas.width() as f64;
|
||||
let ch = self.canvas.height() as f64;
|
||||
if x < 0.0 || x >= cw || y < 0.0 || y >= ch { return -1; }
|
||||
|
||||
let key_w = self.key_w();
|
||||
let bk_w = self.bk_w();
|
||||
let bk_h = self.bk_h();
|
||||
|
||||
// Black keys first (they render on top of white keys)
|
||||
for oct in 0..NUM_OCTAVES {
|
||||
let oct_x = oct as f64 * WHITE_PER_OCT as f64 * key_w;
|
||||
for &(offset, sem) in &BLACK_KEYS {
|
||||
let bx = oct_x + offset * key_w - bk_w * 0.5;
|
||||
if x >= bx && x <= bx + bk_w && y <= bk_h {
|
||||
return (START_NOTE + oct as u8 * 12 + sem) as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// White key
|
||||
let i = (x / key_w) as usize;
|
||||
let oct = i / WHITE_PER_OCT;
|
||||
let sem = WHITE_SEMITONE[i % WHITE_PER_OCT];
|
||||
(START_NOTE + oct as u8 * 12 + sem) as i32
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,14 @@ pub mod oscilloscope;
|
||||
pub mod spectrum;
|
||||
pub mod patchbay;
|
||||
pub mod params;
|
||||
pub mod keyboard;
|
||||
|
||||
pub use engine::AudioEngine;
|
||||
pub use oscilloscope::OscilloscopeView;
|
||||
pub use spectrum::SpectrumView;
|
||||
pub use patchbay::PatchBay;
|
||||
pub use params::SynthParams;
|
||||
pub use keyboard::VirtualKeyboard;
|
||||
|
||||
/// Called once by bootstrap.js after the WASM module loads.
|
||||
#[wasm_bindgen(start)]
|
||||
|
||||
Reference in New Issue
Block a user