diff --git a/CHANGELOG.md b/CHANGELOG.md index ded46a42..73d22883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Tests: `tests/beat_converter.test.js`. ### Added +- **The piano roll's left axis is now a real keyboard.** In keys/piano view + the note-label column is drawn as an actual keyboard laid on its side — + white/black keys shaded like the real thing (black keys inset from the front + edge so the white tails read between them), C rows labelled with their octave, + and separators only where two white keys meet (E–F, B–C). **Click a key to + hear its pitch**: a gentle, hearing-safe audition voice (soft attack, low + peak, routed through the master limiter, ~⅓-second decay) plays the row's + equal-tempered pitch. Left-click only, so right-click still opens the context + menu; it never adds or selects a note, and it's silent until the audio + context is running (autoplay-gated). String view is unchanged. Tests: + `tests/keyboard_gutter.test.js`. - **Piano roll: cycle a fretted note through its same-pitch positions (Shift+↑/↓).** A fretted part shown in the piano roll is read-only — the roll's Y axis is pitch, so the string axis that Shift+↑/↓ walks in String diff --git a/screen.js b/screen.js index c0f46e29..9edb3445 100644 --- a/screen.js +++ b/screen.js @@ -1014,6 +1014,24 @@ function pianoLaneCount() { return pianoRange.hi - pianoRange.lo + 1; } function midiToNote(midi) { return PIANO_NOTE_NAMES[midi % 12] + (Math.floor(midi / 12) - 1); } function isBlackKey(midi) { const pc = midi % 12; return pc===1||pc===3||pc===6||pc===8||pc===10; } +/* @pure:midi-freq:start */ +// Equal-tempered frequency (Hz) of a MIDI note: A4 (69) = 440. Used by the +// keyboard-gutter audition (click a key → hear its pitch). Returns 0 for a +// non-finite input so a caller never schedules a NaN-frequency oscillator. +function midiToFreq(midi) { + const m = Number(midi); + if (!Number.isFinite(m)) return 0; + return 440 * Math.pow(2, (m - 69) / 12); +} + +// Is (x, y) inside the piano keyboard gutter — the LABEL_W-wide column beside +// the roll's pitch lanes? Used to route a click to pitch audition instead of +// the note-edit pipeline. Half-open on every edge so it never overlaps the +// note area (x >= labelW) or the waveform/beat strips above/below. +function _inKeyboardGutterPure(x, y, labelW, waveformTop, laneBottom) { + return x >= 0 && x < labelW && y >= waveformTop && y < laneBottom; +} +/* @pure:midi-freq:end */ function noteToMidi(string, fret) { return string * 24 + fret; } function midiToString(midi) { return Math.floor(midi / 24); } @@ -2385,24 +2403,54 @@ function drawLabels(w) { } } +// The left axis of the piano roll is drawn as an actual keyboard gutter: one +// key per MIDI row, white/black shaded like a real keyboard laid on its side, +// C rows labelled with their octave. It's clickable (see onMouseDown) to +// audition the pitch. Black keys are inset from the front (right) edge so the +// white keys' tails read between them, exactly as on a side-on keyboard. +const _GUTTER_BLACK_INSET = 0.42; // fraction of LABEL_W the black key leaves as white tail on the right function drawPianoLabels() { - // MIDI note labels on the left axis ctx.font = '8px monospace'; - ctx.textAlign = 'center'; + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; + const blackW = LABEL_W * (1 - _GUTTER_BLACK_INSET); for (let midi = pianoRange.lo; midi <= pianoRange.hi; midi++) { const y = midiToY(midi); - ctx.fillStyle = '#0a0a1a'; + const black = isBlackKey(midi); + // White base for every row (the black key's tail shows on the right). + ctx.fillStyle = '#c9c9d6'; ctx.fillRect(0, y, LABEL_W, PIANO_LANE_H); - - // Only label C notes and F notes to avoid clutter - if (midi % 12 === 0 || midi % 12 === 5) { - const octave = Math.floor(midi / 12) - 1; - const color = PIANO_OCTAVE_COLORS[Math.min(octave + 1, PIANO_OCTAVE_COLORS.length - 1)]; - ctx.fillStyle = color; - ctx.fillText(midiToNote(midi), LABEL_W / 2, y + PIANO_LANE_H / 2); + if (black) { + // Black key: a darker bar from the back (left) edge, leaving the + // white tail on the right — the side-on keyboard read. + ctx.fillStyle = '#1b1b2a'; + ctx.fillRect(0, y, blackW, PIANO_LANE_H); + } + // Row separators only between two adjacent WHITE keys (E–F, B–C) — the + // spots a real keyboard has no black key between, so the boundary needs + // a drawn line to read as two distinct keys. + if (!black && !isBlackKey(midi + 1) && midi < pianoRange.hi) { + ctx.strokeStyle = '#9a9aac'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, y + 0.5); + ctx.lineTo(LABEL_W, y + 0.5); + ctx.stroke(); + } + // Label C rows with their octave (e.g. C4), on the white tail so it + // stays legible whether or not the row is a black key. + if (midi % 12 === 0 && PIANO_LANE_H >= 7) { + ctx.fillStyle = '#3a3a4a'; + ctx.fillText(midiToNote(midi), LABEL_W - blackW + 2, y + PIANO_LANE_H / 2); } } + // Front-edge divider so the keyboard reads as a panel distinct from the grid. + ctx.strokeStyle = '#2a2a55'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(LABEL_W - 0.5, WAVEFORM_H); + ctx.lineTo(LABEL_W - 0.5, midiToY(pianoRange.lo) + PIANO_LANE_H); + ctx.stroke(); } function drawNotes(w) { @@ -3796,6 +3844,18 @@ function onMouseDown(e) { return; } + // Keyboard gutter (keys/piano view): a click in the left key column + // auditions that row's pitch — no selection, no edit. Left button only so + // right-click still opens the context menu. Ignored in String view (the + // gutter shows string labels there, not keys). + if (e.button === 0 && isKeysMode()) { + const laneBottom = WAVEFORM_H + pianoLaneCount() * PIANO_LANE_H; + if (_inKeyboardGutterPure(x, y, LABEL_W, WAVEFORM_H, laneBottom)) { + _auditionPitch(yToMidi(y)); + return; + } + } + // Tone lane sits in the top TONE_LANE_H px (an overlay on the // waveform's top edge). Hijack the click before the waveform-seek // handler so add/move/select-marker interactions work. @@ -4256,6 +4316,12 @@ function onDblClick(e) { : WAVEFORM_H + lanes() * LANE_H; if (y < WAVEFORM_H || y > laneBottom) return; + // The keyboard gutter (keys/piano view) is audition-only — see onMouseDown. + // A double-click there must NOT open the Add Note dialog; the gutter never + // adds or selects a note. String view has no key gutter, so this is scoped + // to keys mode where laneBottom already equals the gutter's lower edge. + if (keysMode && _inKeyboardGutterPure(x, y, LABEL_W, WAVEFORM_H, laneBottom)) return; + const idx = hitNote(x, y); if (idx >= 0) return; // double-click on existing note = no-op @@ -7250,6 +7316,37 @@ function _editBlipAt() { _guideVoices = _guideVoices.filter(v => v.until > nowCtx); } } + +// Audition one pitch for the keyboard gutter (click a piano key → hear it). +// A gentle, hearing-safe voice through the master limiter (soft attack, ~0.28 +// peak, ~320 ms decay) — the same envelope shape as the edit blip but pitched +// and a touch longer, so it reads as a note rather than a tick. No-op when the +// context isn't running (autoplay-gated) or the pitch is out of audible range. +function _auditionPitch(midi) { + if (!S.audioCtx || S.audioCtx.state !== 'running') return; + const freq = midiToFreq(midi); + if (!(freq > 0) || freq > 20000) return; + const bus = _ensureMasterBus(); + if (!bus) return; + const ctx = S.audioCtx; + const when = ctx.currentTime; + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.value = freq; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, when); + g.gain.exponentialRampToValueAtTime(0.28, when + 0.006); + g.gain.exponentialRampToValueAtTime(0.0001, when + 0.32); + osc.connect(g); + g.connect(bus.limiter); + osc.start(when); + osc.stop(when + 0.34); + _guideVoices.push({ osc, gain: g, until: when + 0.34 }); + if (_guideVoices.length > 64) { + const nowCtx = ctx.currentTime; + _guideVoices = _guideVoices.filter(v => v.until > nowCtx); + } +} /* @pure:audio-bus:end */ // Event times for the active editing surface: the drum grid claps drum hits, diff --git a/tests/keyboard_gutter.test.js b/tests/keyboard_gutter.test.js new file mode 100644 index 00000000..3b96a406 --- /dev/null +++ b/tests/keyboard_gutter.test.js @@ -0,0 +1,135 @@ +'use strict'; +/* + * Tests for the piano-roll keyboard gutter (DAW 4.1): + * midiToFreq (equal-tempered pitch), _inKeyboardGutterPure (the click hit + * region), and _auditionPitch (the click-to-hear voice: autoplay guard + + * one gentle scheduled oscillator at the row's pitch). + * + * All fail on main — none of these exist there. + * + * Run: node tests/keyboard_gutter.test.js + */ +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + +function extractBlock(name) { + const re = new RegExp('/\\* @pure:' + name + ':start \\*/[\\s\\S]*?/\\* @pure:' + name + ':end \\*/'); + const m = src.match(re); + if (!m) { console.error(`FAIL: @pure:${name} not found`); process.exit(1); } + return m[0]; +} +function extractFn(name) { + const start = src.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error('unbalanced braces extracting ' + name); +} + +let passed = 0, failed = 0; +function t(name, fn) { + try { fn(); passed++; console.log(' ok ' + name); } + catch (e) { failed++; console.error(' FAIL ' + name + '\n ' + (e && e.message)); } +} + +const P = new Function('"use strict";' + extractBlock('midi-freq') + + '\nreturn { midiToFreq, _inKeyboardGutterPure };')(); + +// ── midiToFreq ─────────────────────────────────────────────────────── + +t('midiToFreq: equal temperament anchored at A4 = 440', () => { + assert.strictEqual(P.midiToFreq(69), 440); + assert.strictEqual(P.midiToFreq(81), 880, 'A5 one octave up'); + assert.strictEqual(P.midiToFreq(57), 220, 'A3 one octave down'); + assert.ok(Math.abs(P.midiToFreq(60) - 261.6256) < 0.001, 'middle C ≈ 261.63'); + assert.ok(Math.abs(P.midiToFreq(64) - 329.6276) < 0.001, 'E4 ≈ 329.63'); +}); + +t('midiToFreq: non-finite input → 0 (never schedules a NaN oscillator)', () => { + assert.strictEqual(P.midiToFreq(NaN), 0); + assert.strictEqual(P.midiToFreq(undefined), 0); + assert.strictEqual(P.midiToFreq('x'), 0); +}); + +// ── _inKeyboardGutterPure ──────────────────────────────────────────── + +t('_inKeyboardGutterPure: inside the LABEL_W column beside the pitch lanes', () => { + const W = 52, top = 70, bottom = 400; + assert.strictEqual(P._inKeyboardGutterPure(10, 100, W, top, bottom), true); + assert.strictEqual(P._inKeyboardGutterPure(0, top, W, top, bottom), true, 'top-left corner included'); +}); + +t('_inKeyboardGutterPure: half-open on every edge (never overlaps notes/strips)', () => { + const W = 52, top = 70, bottom = 400; + assert.strictEqual(P._inKeyboardGutterPure(52, 100, W, top, bottom), false, 'x==labelW is the note area'); + assert.strictEqual(P._inKeyboardGutterPure(-1, 100, W, top, bottom), false); + assert.strictEqual(P._inKeyboardGutterPure(10, 69, W, top, bottom), false, 'above the lanes (waveform)'); + assert.strictEqual(P._inKeyboardGutterPure(10, 400, W, top, bottom), false, 'at/below the last lane'); +}); + +// ── _auditionPitch: guard + scheduling ─────────────────────────────── + +function makeAuditionEnv(ctxState) { + const scheduled = []; + let ctx = null; + if (ctxState) { + ctx = { + state: ctxState, + currentTime: 5, + createOscillator: () => { + const o = { type: '', frequency: {}, connect() {}, start(w) { o._start = w; }, stop(w) { o._stop = w; } }; + scheduled.push(o); + return o; + }, + createGain: () => ({ gain: { setValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect() {} }), + }; + } + const S = { audioCtx: ctx }; + const env = new Function( + 'S', 'midiToFreq', '_ensureMasterBus', '_guideVoices', + '"use strict";' + extractFn('_auditionPitch') + + '\nreturn { _auditionPitch, voices: _guideVoices };' + ); + const guideVoices = []; + // midiToFreq is small; re-provide it rather than extract twice. + const midiToFreq = m => (Number.isFinite(Number(m)) ? 440 * Math.pow(2, (Number(m) - 69) / 12) : 0); + const api = env(S, midiToFreq, () => ({ limiter: { } }), guideVoices); + return { api, scheduled, guideVoices }; +} + +t('_auditionPitch: no-op when the audio context is absent or suspended', () => { + const absent = makeAuditionEnv(null); + absent.api._auditionPitch(60); + assert.strictEqual(absent.scheduled.length, 0, 'no ctx → nothing scheduled'); + assert.strictEqual(absent.guideVoices.length, 0); + + const suspended = makeAuditionEnv('suspended'); + suspended.api._auditionPitch(60); + assert.strictEqual(suspended.scheduled.length, 0, 'suspended ctx → nothing scheduled'); +}); + +t('_auditionPitch: a running ctx schedules exactly one voice at the row pitch', () => { + const { api, scheduled, guideVoices } = makeAuditionEnv('running'); + api._auditionPitch(69); + assert.strictEqual(scheduled.length, 1, 'one oscillator'); + assert.strictEqual(scheduled[0].frequency.value, 440, 'A4 → 440 Hz'); + assert.strictEqual(scheduled[0].type, 'triangle'); + assert.ok(scheduled[0]._stop > scheduled[0]._start, 'has a positive duration'); + assert.strictEqual(guideVoices.length, 1, 'tracked for cleanup'); +}); + +t('_auditionPitch: an out-of-audible pitch is refused', () => { + const { api, scheduled } = makeAuditionEnv('running'); + api._auditionPitch(200); // ~2.1 MHz — above the 20 kHz guard + assert.strictEqual(scheduled.length, 0, 'inaudibly-high pitch schedules nothing'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/tests/keyboard_gutter_dblclick.test.js b/tests/keyboard_gutter_dblclick.test.js new file mode 100644 index 00000000..757ce2d8 --- /dev/null +++ b/tests/keyboard_gutter_dblclick.test.js @@ -0,0 +1,103 @@ +'use strict'; +/* + * Regression: a DOUBLE-click in the piano-roll keyboard gutter must NOT open + * the Add Note dialog. The gutter is audition-only (single-click plays the + * row's pitch via onMouseDown); before the fix, onDblClick had no gutter guard, + * so double-clicking a key fell through to hitNote()==-1 and called + * showAddNote() at time 0 — silently inserting a note the user never asked for. + * This violates the PR contract ("it never adds or selects a note"). + * + * Pins the real onDblClick + the real _inKeyboardGutterPure from screen.js + * (single browser IIFE) by brace-matching, and asserts showAddNote is NOT + * called for a gutter double-click, but IS called for a click in the note area. + * Fails on pre-fix screen.js (showAddNote fires in the gutter). + * + * Run: node tests/keyboard_gutter_dblclick.test.js + */ +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + +function extractFn(name) { + const start = src.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error('unbalanced braces extracting ' + name); +} +function extractBlock(name) { + const re = new RegExp('/\\* @pure:' + name + ':start \\*/[\\s\\S]*?/\\* @pure:' + name + ':end \\*/'); + const m = src.match(re); + assert.ok(m, `@pure:${name} block must exist`); + return m[0]; +} + +// Real _inKeyboardGutterPure straight from the source. +const { _inKeyboardGutterPure } = new Function( + '"use strict";' + extractBlock('midi-freq') + '\nreturn { _inKeyboardGutterPure };' +)(); + +// Geometry matching screen.js defaults. +const LABEL_W = 52, WAVEFORM_H = 70, PIANO_LANE_H = 10; +const pianoRange = { lo: 36, hi: 96 }; + +// Build an onDblClick with every free global injected. showAddNote is a spy. +function makeDblClick(keysMode) { + const calls = { showAddNote: 0, lockNotice: 0 }; + const deps = { + S: { partsViewMode: false, drumEditMode: false, tempoMapMode: false }, + _partsViewOnDblClick: () => {}, + _recState: 'idle', + getMousePos: (e) => ({ x: e.x, y: e.y }), + isKeysMode: () => keysMode, + WAVEFORM_H, PIANO_LANE_H, LANE_H: 44, LABEL_W, + pianoLaneCount: () => pianoRange.hi - pianoRange.lo + 1, + lanes: () => 6, + _inKeyboardGutterPure, + hitNote: () => -1, // empty space — no existing note under cursor + _rollReadOnly: () => false, // authoring allowed + _rollLockNotice: () => { calls.lockNotice++; }, + snapTime: (t) => t, + xToTime: (x) => (x - LABEL_W) / 1 + 0, + yToMidi: (y) => pianoRange.hi - Math.floor((y - WAVEFORM_H) / PIANO_LANE_H), + midiToString: (m) => Math.floor(m / 24), + midiToFret: (m) => m % 24, + yToStr: () => 0, + showAddNote: () => { calls.showAddNote++; }, + }; + const names = Object.keys(deps); + const fn = new Function(...names, '"use strict";' + extractFn('onDblClick') + '\nreturn onDblClick;')( + ...names.map((n) => deps[n]) + ); + return { fn, calls }; +} + +let passed = 0, failed = 0; +function t(name, run) { + try { run(); passed++; console.log(' ok ' + name); } + catch (e) { failed++; console.error(' FAIL ' + name + '\n ' + (e && e.message)); } +} + +// A y inside the lanes; an x inside the gutter (< LABEL_W). +const yInLanes = WAVEFORM_H + 5; + +t('double-click in the keyboard gutter does NOT open Add Note (keys mode)', () => { + const { fn, calls } = makeDblClick(true); + fn({ x: 10, y: yInLanes, clientX: 10, clientY: yInLanes }); + assert.strictEqual(calls.showAddNote, 0, 'gutter double-click must not add a note'); +}); + +t('double-click in the note area (x >= LABEL_W) still opens Add Note', () => { + const { fn, calls } = makeDblClick(true); + fn({ x: LABEL_W + 20, y: yInLanes, clientX: LABEL_W + 20, clientY: yInLanes }); + assert.strictEqual(calls.showAddNote, 1, 'note-area double-click still authors'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0);