Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 107 additions & 10 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions tests/keyboard_gutter.test.js
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading