Add a virtual keyboard to the visualiser
This commit is contained in:
@@ -7,7 +7,8 @@
|
|||||||
"Bash(find /Users/mattsp/.cargo/registry/src -name memory.x -path */rp*)",
|
"Bash(find /Users/mattsp/.cargo/registry/src -name memory.x -path */rp*)",
|
||||||
"Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)",
|
"Bash(find /Users/mattsp/.cargo/registry/src -name build.rs -path */rp-pac*)",
|
||||||
"Bash(chmod +x /Users/mattsp/projects/sound/build-web.sh)",
|
"Bash(chmod +x /Users/mattsp/projects/sound/build-web.sh)",
|
||||||
"Bash(/Users/mattsp/projects/sound/build-web.sh)"
|
"Bash(/Users/mattsp/projects/sound/build-web.sh)",
|
||||||
|
"Bash(wasm-pack build:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ impl AudioEngine {
|
|||||||
self.analyser.clone()
|
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> {
|
pub fn set_params(&self, _json: &str) -> Result<(), JsValue> {
|
||||||
// TODO: parse JSON and post to AudioWorkletNode MessagePort
|
// TODO: parse JSON and post to AudioWorkletNode MessagePort
|
||||||
Ok(())
|
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 spectrum;
|
||||||
pub mod patchbay;
|
pub mod patchbay;
|
||||||
pub mod params;
|
pub mod params;
|
||||||
|
pub mod keyboard;
|
||||||
|
|
||||||
pub use engine::AudioEngine;
|
pub use engine::AudioEngine;
|
||||||
pub use oscilloscope::OscilloscopeView;
|
pub use oscilloscope::OscilloscopeView;
|
||||||
pub use spectrum::SpectrumView;
|
pub use spectrum::SpectrumView;
|
||||||
pub use patchbay::PatchBay;
|
pub use patchbay::PatchBay;
|
||||||
pub use params::SynthParams;
|
pub use params::SynthParams;
|
||||||
|
pub use keyboard::VirtualKeyboard;
|
||||||
|
|
||||||
/// Called once by bootstrap.js after the WASM module loads.
|
/// Called once by bootstrap.js after the WASM module loads.
|
||||||
#[wasm_bindgen(start)]
|
#[wasm_bindgen(start)]
|
||||||
|
|||||||
198
www/bootstrap.js
vendored
198
www/bootstrap.js
vendored
@@ -2,8 +2,9 @@
|
|||||||
* bootstrap.js — ES module entry point.
|
* bootstrap.js — ES module entry point.
|
||||||
*
|
*
|
||||||
* Initialises the WASM module, wires canvas elements to Rust-exported types,
|
* Initialises the WASM module, wires canvas elements to Rust-exported types,
|
||||||
* keeps canvas drawing buffers in sync with their CSS size, and provides a
|
* keeps canvas drawing buffers in sync with their CSS size, provides a
|
||||||
* draggable resize handle for the patch bay panel.
|
* draggable resize handle for the patch bay, and drives a monophonic
|
||||||
|
* synthesiser from the virtual keyboard and computer keyboard.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import init, {
|
import init, {
|
||||||
@@ -11,16 +12,14 @@ import init, {
|
|||||||
OscilloscopeView,
|
OscilloscopeView,
|
||||||
SpectrumView,
|
SpectrumView,
|
||||||
PatchBay,
|
PatchBay,
|
||||||
|
VirtualKeyboard,
|
||||||
SynthParams,
|
SynthParams,
|
||||||
} from "./pkg/synth_visualiser.js";
|
} from "./pkg/synth_visualiser.js";
|
||||||
|
|
||||||
// ── Canvas buffer sizing ──────────────────────────────────────────────────────
|
// ── Canvas buffer sizing ──────────────────────────────────────────────────────
|
||||||
// A <canvas> has two independent sizes:
|
// The canvas *attribute* (width/height) sets the drawing-buffer resolution.
|
||||||
// - CSS display size (width/height CSS properties) — how large it appears
|
// The CSS size only controls how it's displayed. We keep them in sync so the
|
||||||
// - Drawing buffer (width/height HTML attributes) — actual pixel resolution
|
// Rust code always draws at 1:1 pixels.
|
||||||
//
|
|
||||||
// We must keep them in sync; if the buffer is smaller than the display size the
|
|
||||||
// browser stretches it and everything looks blurry / oversized.
|
|
||||||
|
|
||||||
function fitCanvas(canvas) {
|
function fitCanvas(canvas) {
|
||||||
const w = Math.round(canvas.clientWidth);
|
const w = Math.round(canvas.clientWidth);
|
||||||
@@ -36,9 +35,7 @@ function fitCanvas(canvas) {
|
|||||||
function initResizeHandle() {
|
function initResizeHandle() {
|
||||||
const handle = document.getElementById("resize-handle");
|
const handle = document.getElementById("resize-handle");
|
||||||
const panel = document.getElementById("patchbay-panel");
|
const panel = document.getElementById("patchbay-panel");
|
||||||
let dragging = false;
|
let dragging = false, startY = 0, startH = 0;
|
||||||
let startY = 0;
|
|
||||||
let startH = 0;
|
|
||||||
|
|
||||||
handle.addEventListener("pointerdown", e => {
|
handle.addEventListener("pointerdown", e => {
|
||||||
dragging = true;
|
dragging = true;
|
||||||
@@ -47,19 +44,81 @@ function initResizeHandle() {
|
|||||||
handle.setPointerCapture(e.pointerId);
|
handle.setPointerCapture(e.pointerId);
|
||||||
handle.classList.add("active");
|
handle.classList.add("active");
|
||||||
});
|
});
|
||||||
|
|
||||||
handle.addEventListener("pointermove", e => {
|
handle.addEventListener("pointermove", e => {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
// Dragging up (negative dy) increases the panel height
|
panel.style.height = Math.max(80, startH - (e.clientY - startY)) + "px";
|
||||||
const dy = e.clientY - startY;
|
|
||||||
const h = Math.max(80, startH - dy);
|
|
||||||
panel.style.height = h + "px";
|
|
||||||
});
|
});
|
||||||
|
const stopDrag = () => { dragging = false; handle.classList.remove("active"); };
|
||||||
handle.addEventListener("pointerup", () => { dragging = false; handle.classList.remove("active"); });
|
handle.addEventListener("pointerup", stopDrag);
|
||||||
handle.addEventListener("pointercancel",() => { dragging = false; handle.classList.remove("active"); });
|
handle.addEventListener("pointercancel", stopDrag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Monophonic synthesiser ────────────────────────────────────────────────────
|
||||||
|
// Uses the Web Audio API directly. All notes are routed through the engine's
|
||||||
|
// input GainNode → AnalyserNode → speakers so the oscilloscope and spectrum
|
||||||
|
// analyser reflect the keyboard output.
|
||||||
|
|
||||||
|
function createSynth(audioCtx, inputNode) {
|
||||||
|
let osc = null;
|
||||||
|
let envGain = null;
|
||||||
|
let noteCache = -1;
|
||||||
|
|
||||||
|
function midiToHz(n) {
|
||||||
|
return 440 * Math.pow(2, (n - 69) / 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureRunning() {
|
||||||
|
if (audioCtx.state !== "running") await audioCtx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async noteOn(midiNote) {
|
||||||
|
await ensureRunning();
|
||||||
|
if (midiNote === noteCache) return;
|
||||||
|
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
if (!osc) {
|
||||||
|
// Fresh attack
|
||||||
|
osc = audioCtx.createOscillator();
|
||||||
|
envGain = audioCtx.createGain();
|
||||||
|
osc.type = "sawtooth";
|
||||||
|
osc.frequency.value = midiToHz(midiNote);
|
||||||
|
envGain.gain.setValueAtTime(0.0001, now);
|
||||||
|
envGain.gain.exponentialRampToValueAtTime(0.3, now + 0.015);
|
||||||
|
osc.connect(envGain);
|
||||||
|
envGain.connect(inputNode);
|
||||||
|
osc.start();
|
||||||
|
} else {
|
||||||
|
// Glide to new pitch without retriggering the envelope
|
||||||
|
osc.frequency.setTargetAtTime(midiToHz(midiNote), now, 0.015);
|
||||||
|
}
|
||||||
|
noteCache = midiNote;
|
||||||
|
},
|
||||||
|
|
||||||
|
noteOff() {
|
||||||
|
if (!osc) return;
|
||||||
|
const now = audioCtx.currentTime;
|
||||||
|
envGain.gain.cancelScheduledValues(now);
|
||||||
|
envGain.gain.setValueAtTime(envGain.gain.value, now);
|
||||||
|
envGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.28);
|
||||||
|
const o = osc, g = envGain;
|
||||||
|
setTimeout(() => { try { o.stop(); o.disconnect(); g.disconnect(); } catch (_) {} }, 400);
|
||||||
|
osc = null; envGain = null; noteCache = -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
activeNote() { return noteCache; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Computer keyboard map (standard DAW layout) ───────────────────────────────
|
||||||
|
// Lower row Z–M → C3–B3, with S D G H J for sharps
|
||||||
|
// Upper row Q–U → C4–B4, with 2 3 5 6 7 for sharps
|
||||||
|
|
||||||
|
const KEY_NOTE = {
|
||||||
|
z:48, s:49, x:50, d:51, c:52, v:53, g:54, b:55, h:56, n:57, j:58, m:59,
|
||||||
|
q:60, 2:61, w:62, 3:63, e:64, r:65, 5:66, t:67, 6:68, y:69, 7:70, u:71,
|
||||||
|
};
|
||||||
|
|
||||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const loader = document.getElementById("loader");
|
const loader = document.getElementById("loader");
|
||||||
@@ -73,6 +132,7 @@ async function bootstrap() {
|
|||||||
try {
|
try {
|
||||||
await init();
|
await init();
|
||||||
|
|
||||||
|
// ── WASM objects ──────────────────────────────────────────────────────
|
||||||
const engine = new AudioEngine();
|
const engine = new AudioEngine();
|
||||||
await engine.attach();
|
await engine.attach();
|
||||||
|
|
||||||
@@ -80,33 +140,103 @@ async function bootstrap() {
|
|||||||
const oscilloscope = new OscilloscopeView("oscilloscope-canvas", analyser);
|
const oscilloscope = new OscilloscopeView("oscilloscope-canvas", analyser);
|
||||||
const spectrum = new SpectrumView("spectrum-canvas", analyser);
|
const spectrum = new SpectrumView("spectrum-canvas", analyser);
|
||||||
const patchbay = new PatchBay("patchbay-canvas");
|
const patchbay = new PatchBay("patchbay-canvas");
|
||||||
|
const keyboard = new VirtualKeyboard("keyboard-canvas");
|
||||||
|
|
||||||
// Fit all canvas buffers to their current CSS layout size before we
|
// ── Canvas sizing ─────────────────────────────────────────────────────
|
||||||
// ask Rust for canvas.width() to position the default modules.
|
|
||||||
const pbCanvas = document.getElementById("patchbay-canvas");
|
const pbCanvas = document.getElementById("patchbay-canvas");
|
||||||
const oscCanvas = document.getElementById("oscilloscope-canvas");
|
const oscCanvas = document.getElementById("oscilloscope-canvas");
|
||||||
const specCanvas = document.getElementById("spectrum-canvas");
|
const spCanvas = document.getElementById("spectrum-canvas");
|
||||||
const allCanvases = [oscCanvas, specCanvas, pbCanvas];
|
const kbCanvas = document.getElementById("keyboard-canvas");
|
||||||
|
const allCanvases = [oscCanvas, spCanvas, pbCanvas, kbCanvas];
|
||||||
|
|
||||||
allCanvases.forEach(fitCanvas);
|
allCanvases.forEach(fitCanvas);
|
||||||
|
|
||||||
// Seed a default patch using the now-correct canvas width.
|
// Keep buffers in sync when panels are resized
|
||||||
|
const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas));
|
||||||
|
allCanvases.forEach(c => ro.observe(c));
|
||||||
|
|
||||||
|
// ── Default patch bay layout ──────────────────────────────────────────
|
||||||
const cw = pbCanvas.width || pbCanvas.clientWidth || 800;
|
const cw = pbCanvas.width || pbCanvas.clientWidth || 800;
|
||||||
patchbay.add_module("vco", cw * 0.12, 80);
|
patchbay.add_module("vco", cw * 0.12, 80);
|
||||||
patchbay.add_module("adsr", cw * 0.32, 80);
|
patchbay.add_module("adsr", cw * 0.32, 80);
|
||||||
patchbay.add_module("svf", cw * 0.55, 80);
|
patchbay.add_module("svf", cw * 0.55, 80);
|
||||||
patchbay.add_module("vca", cw * 0.76, 80);
|
patchbay.add_module("vca", cw * 0.76, 80);
|
||||||
|
|
||||||
// Keep canvas buffers in sync whenever the panel is resized.
|
// ── Patch bay pointer events ──────────────────────────────────────────
|
||||||
const ro = new ResizeObserver(() => allCanvases.forEach(fitCanvas));
|
|
||||||
allCanvases.forEach(c => ro.observe(c));
|
|
||||||
|
|
||||||
// Patch bay pointer events
|
|
||||||
pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY));
|
pbCanvas.addEventListener("pointerdown", e => patchbay.on_pointer_down(e.offsetX, e.offsetY));
|
||||||
pbCanvas.addEventListener("pointermove", e => patchbay.on_pointer_move(e.offsetX, e.offsetY));
|
pbCanvas.addEventListener("pointermove", e => patchbay.on_pointer_move(e.offsetX, e.offsetY));
|
||||||
pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY));
|
pbCanvas.addEventListener("pointerup", e => patchbay.on_pointer_up(e.offsetX, e.offsetY));
|
||||||
pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY));
|
pbCanvas.addEventListener("dblclick", e => patchbay.on_double_click(e.offsetX, e.offsetY));
|
||||||
|
|
||||||
|
// ── Synthesiser ───────────────────────────────────────────────────────
|
||||||
|
const audioCtx = engine.audio_context();
|
||||||
|
const inputNode = engine.input_node();
|
||||||
|
const synth = createSynth(audioCtx, inputNode);
|
||||||
|
|
||||||
|
// ── Virtual keyboard pointer events ───────────────────────────────────
|
||||||
|
let pointerDown = false;
|
||||||
|
|
||||||
|
kbCanvas.addEventListener("pointerdown", e => {
|
||||||
|
pointerDown = true;
|
||||||
|
kbCanvas.setPointerCapture(e.pointerId);
|
||||||
|
const note = keyboard.on_pointer_down(e.offsetX, e.offsetY);
|
||||||
|
if (note >= 0) synth.noteOn(note);
|
||||||
|
else synth.noteOff();
|
||||||
|
});
|
||||||
|
|
||||||
|
kbCanvas.addEventListener("pointermove", e => {
|
||||||
|
const result = keyboard.on_pointer_move(e.offsetX, e.offsetY);
|
||||||
|
if (result === -2) return; // hover only, no synthesis change
|
||||||
|
if (pointerDown) {
|
||||||
|
if (result >= 0) synth.noteOn(result); // noteOn handles glide
|
||||||
|
else synth.noteOff();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
kbCanvas.addEventListener("pointerup", e => {
|
||||||
|
pointerDown = false;
|
||||||
|
keyboard.on_pointer_up(e.offsetX, e.offsetY);
|
||||||
|
synth.noteOff();
|
||||||
|
});
|
||||||
|
|
||||||
|
kbCanvas.addEventListener("pointerleave", () => {
|
||||||
|
const result = keyboard.on_pointer_leave();
|
||||||
|
if (result === -1) synth.noteOff(); // was pressing
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Computer keyboard events ──────────────────────────────────────────
|
||||||
|
const heldKeys = new Set();
|
||||||
|
|
||||||
|
document.addEventListener("keydown", e => {
|
||||||
|
if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
const note = KEY_NOTE[e.key.toLowerCase()];
|
||||||
|
if (note === undefined) return;
|
||||||
|
heldKeys.add(e.key.toLowerCase());
|
||||||
|
keyboard.set_active_note(note);
|
||||||
|
synth.noteOn(note);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keyup", e => {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
heldKeys.delete(key);
|
||||||
|
const note = KEY_NOTE[key];
|
||||||
|
if (note === undefined) return;
|
||||||
|
|
||||||
|
// Only stop if no other mapped key is still held
|
||||||
|
const anyHeld = [...heldKeys].some(k => KEY_NOTE[k] !== undefined);
|
||||||
|
if (!anyHeld) {
|
||||||
|
keyboard.set_active_note(-1);
|
||||||
|
synth.noteOff();
|
||||||
|
} else {
|
||||||
|
// Switch to the last remaining held key
|
||||||
|
const lastKey = [...heldKeys].filter(k => KEY_NOTE[k] !== undefined).at(-1);
|
||||||
|
const lastNote = KEY_NOTE[lastKey];
|
||||||
|
keyboard.set_active_note(lastNote);
|
||||||
|
synth.noteOn(lastNote);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Params + engine start ─────────────────────────────────────────────
|
||||||
const params = new SynthParams();
|
const params = new SynthParams();
|
||||||
engine.set_params(params.to_json());
|
engine.set_params(params.to_json());
|
||||||
|
|
||||||
@@ -114,11 +244,13 @@ async function bootstrap() {
|
|||||||
status.textContent = "Running";
|
status.textContent = "Running";
|
||||||
engine.start();
|
engine.start();
|
||||||
|
|
||||||
|
// ── Render loop ───────────────────────────────────────────────────────
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
function frame(now) {
|
function frame(now) {
|
||||||
oscilloscope.draw();
|
oscilloscope.draw();
|
||||||
spectrum.draw();
|
spectrum.draw();
|
||||||
patchbay.draw();
|
patchbay.draw();
|
||||||
|
keyboard.draw();
|
||||||
frameTime.textContent = `frame: ${(now - last).toFixed(1)} ms`;
|
frameTime.textContent = `frame: ${(now - last).toFixed(1)} ms`;
|
||||||
last = now;
|
last = now;
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
@@ -127,6 +259,14 @@ async function bootstrap() {
|
|||||||
|
|
||||||
loader.classList.add("hidden");
|
loader.classList.add("hidden");
|
||||||
|
|
||||||
|
// Show keyboard hint briefly then fade it
|
||||||
|
const hint = document.getElementById("kb-hint");
|
||||||
|
if (hint) {
|
||||||
|
hint.style.transition = "opacity 1s";
|
||||||
|
setTimeout(() => { hint.style.opacity = "1"; }, 2000);
|
||||||
|
setTimeout(() => { hint.style.opacity = "0"; }, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[bootstrap] Fatal:", err);
|
console.error("[bootstrap] Fatal:", err);
|
||||||
loader.textContent = `Error: ${err.message ?? err}`;
|
loader.textContent = `Error: ${err.message ?? err}`;
|
||||||
|
|||||||
@@ -88,7 +88,18 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: var(--patchbay-h);
|
height: var(--patchbay-h);
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
max-height: calc(100vh - 120px);
|
max-height: calc(100vh - 220px);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Virtual keyboard — fixed height */
|
||||||
|
.panel--keyboard {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -165,6 +176,11 @@
|
|||||||
<div class="panel__label">Patch Bay</div>
|
<div class="panel__label">Patch Bay</div>
|
||||||
<canvas id="patchbay-canvas"></canvas>
|
<canvas id="patchbay-canvas"></canvas>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel panel--keyboard">
|
||||||
|
<div class="panel__label">Keyboard <span id="kb-hint" style="color:#333;font-size:0.6rem;">Z–M · Q–U · S D G H J · 2 3 5 6 7</span></div>
|
||||||
|
<canvas id="keyboard-canvas"></canvas>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
Reference in New Issue
Block a user