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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Tests: `tests/beat_converter.test.js`.

### Added
- **Detect key from the notes.** A new **Detect** button in the key controls
guesses the current part's key from its pitch-class content (the
Krumhansl–Schmuckler algorithm — Pearson correlation against the standard
major/minor key profiles) and sets it, turning the in-key highlight on so the
result is visible. Duration-weighted (a held note counts more than a passing
one, with a small floor so staccato still registers) and capo/tuning-aware for
fretted parts. It's a **suggestion, not authoritative** — the tonic/scale
pickers stay editable — and it says nothing when a part has no notes or no
tonal centre. Tests: `tests/key_detect.test.js`.
- **Scale-degree overlay on fretted notes.** With the in-key highlight on, each
note in String view now shows a small scale-degree label in its top-right
corner (`1`, `♭3`, `5`, `♭7`, …) relative to the current key, coloured by
Expand Down
1 change: 1 addition & 0 deletions screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
<select id="editor-key-tonic" onchange="editorSetKeyTonic(this.value)" class="bg-dark-700 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 outline-none" title="Song key (tonic)"></select>
<select id="editor-key-scale" onchange="editorSetKeyScale(this.value)" class="bg-dark-700 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 outline-none" title="Scale / mode"></select>
<button id="editor-key-highlight-btn" onclick="editorToggleKeyHighlight()" class="px-2 py-0.5 bg-dark-600 hover:bg-dark-500 rounded text-xs font-medium" title="Dim out-of-key notes — fretted lanes resolve pitch from tuning + capo; the piano roll also shades out-of-key rows" aria-pressed="false">In-key</button>
<button id="editor-key-detect-btn" onclick="editorDetectKey()" class="px-2 py-0.5 bg-dark-600 hover:bg-dark-500 rounded text-xs font-medium" title="Guess the key from this part's notes (Krumhansl profiles) and set it — a suggestion, editable in the picker">Detect</button>
</div>

<div class="h-4 w-px bg-gray-700"></div>
Expand Down
83 changes: 83 additions & 0 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,58 @@ function _pcInScalePure(pc, tonicPc, scaleName) {
}
/* @pure:scale:end */

/* @pure:key-detect:start */
// Krumhansl–Kessler key profiles — the relative weight a tonal centre gives
// each scale degree (index 0 = tonic). Used to guess a chart's key from its
// pitch-class content. Standard, well-cited values; ranking only, so their
// absolute scale doesn't matter.
const _KK_MAJOR_PROFILE = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88];
const _KK_MINOR_PROFILE = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17];

// Best-fit key for a 12-bin pitch-class weight histogram (bin 0 = C). This is
// the Krumhansl–Schmuckler algorithm: score all 24 major/minor keys by the
// PEARSON CORRELATION of the histogram with the tonic-rotated profile, and
// return the winner as {tonic, scale, score} (`scale` is a SCALE_INTERVALS id,
// 'major' | 'minor'). Correlation (not a raw dot product) is what makes the
// comparison fair ACROSS modes — the two profiles have different magnitudes, so
// a dot product would systematically favour one; and it makes a profile score a
// perfect 1.0 against its own key. Ties break toward major (scored first).
// Returns null for an empty or perfectly flat histogram (no tonal centre), so
// the caller shows nothing rather than a bogus C major. Pure — no note/DOM.
function _detectKeyPure(pcWeights) {
if (!Array.isArray(pcWeights) || pcWeights.length < 12) return null;
const x = new Array(12);
let sum = 0, total = 0;
for (let i = 0; i < 12; i++) {
const w = Number(pcWeights[i]);
const v = (Number.isFinite(w) && w > 0) ? w : 0;
x[i] = v; sum += v; total += v;
}
if (total <= 0) return null;
const xbar = sum / 12;
let xden = 0;
for (let i = 0; i < 12; i++) xden += (x[i] - xbar) * (x[i] - xbar);
xden = Math.sqrt(xden);
if (xden <= 0) return null; // flat histogram — no tonal centre to find
let best = null;
const modes = [['major', _KK_MAJOR_PROFILE], ['minor', _KK_MINOR_PROFILE]];
for (const [scale, profile] of modes) {
const pbar = profile.reduce((a, b) => a + b, 0) / 12;
let pden = 0;
for (let i = 0; i < 12; i++) pden += (profile[i] - pbar) * (profile[i] - pbar);
pden = Math.sqrt(pden);
for (let tonic = 0; tonic < 12; tonic++) {
let num = 0;
for (let pc = 0; pc < 12; pc++) {
num += (x[pc] - xbar) * (profile[((pc - tonic) % 12 + 12) % 12] - pbar);
}
const score = num / (xden * pden);
if (!best || score > best.score) best = { tonic, scale, score };
}
}
return best;
}
/* @pure:key-detect:end */
/* @pure:scale-degree:start */
// Scale-degree label for a pitch class relative to a tonic (semitones above
// the tonic, 0 = root). Flats for the chromatic degrees (the common Nashville/
Expand Down Expand Up @@ -2048,6 +2100,37 @@ function _editorToggleKeyHighlight() {
}
window.editorToggleKeyHighlight = _editorToggleKeyHighlight;

// Detect the active arrangement's key from its pitch-class content (DAW 4.17)
// and set it as the editor key, turning the in-key highlight on so the result
// is visible. A best-guess suggestion, not authoritative — the picker stays
// editable. Duration-weighted (a held note counts more than a passing one),
// with a small floor so staccato notes still register. Fretted parts resolve
// to sounding pitch (capo/tuning-aware); keys parts use their packed pitch.
window.editorDetectKey = () => {
if (!S.arrangements || !S.arrangements.length) { setStatus('No arrangement to analyse'); return; }
const nn = notes();
if (!nn.length) { setStatus('No notes yet — add some before detecting a key'); return; }
const rctx = typeof _rollPitchCtx === 'function' ? _rollPitchCtx() : null;
const weights = new Array(12).fill(0);
let counted = 0;
for (const n of nn) {
const midi = _rollMidiForNote(n, rctx);
if (!Number.isFinite(midi)) continue;
const pc = ((Math.round(midi) % 12) + 12) % 12;
weights[pc] += Math.max(Number(n.sustain) || 0, 0.1);
counted++;
}
const res = counted ? _detectKeyPure(weights) : null;
if (!res) { setStatus('Could not detect a key from this part'); return; }
S.editorKey = { tonic: res.tonic, scale: res.scale };
try { localStorage.setItem('editorKeyHighlight', '1'); } catch (_) { /* private mode */ }
_persistEditorKey();
_refreshKeyControls();
draw();
const label = (typeof SCALE_LABELS !== 'undefined' && SCALE_LABELS[res.scale]) || res.scale;
setStatus(`Detected key: ${PIANO_NOTE_NAMES[res.tonic]} ${label} — adjust in the picker if it's off`);
};

// ── Per-part view switcher (String · Piano roll) ─────────────────────
let _viewSwitchState = '';
function _refreshViewSwitch() {
Expand Down
92 changes: 92 additions & 0 deletions tests/key_detect.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use strict';
/*
* Tests for passive key detection (DAW 4.17): _detectKeyPure scores a 12-bin
* pitch-class histogram against the 24 major/minor Krumhansl profiles and
* returns the best-fit {tonic, scale}. Suggestion only; nothing mutates state.
*
* Fails on main — the block doesn't exist there.
*
* Run: node tests/key_detect.test.js
*/
const fs = require('fs');
const path = require('path');
const assert = require('assert');

const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
const m = src.match(/\/\* @pure:key-detect:start \*\/[\s\S]*?\/\* @pure:key-detect:end \*\//);
if (!m) { console.error('FAIL: @pure:key-detect block not found'); process.exit(1); }
const K = new Function('"use strict";' + m[0]
+ '\nreturn { _detectKeyPure, _KK_MAJOR_PROFILE, _KK_MINOR_PROFILE };')();

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)); }
}

// Rotate a tonic-0 profile so its tonic sits at pitch class `tonic`.
function rotate(profile, tonic) {
const out = new Array(12);
for (let pc = 0; pc < 12; pc++) out[pc] = profile[((pc - tonic) % 12 + 12) % 12];
return out;
}

// ── exact-profile inputs detect their own key (ranking is correct) ───

t('the C-major profile detects C major', () => {
const r = K._detectKeyPure(K._KK_MAJOR_PROFILE.slice());
assert.deepStrictEqual({ tonic: r.tonic, scale: r.scale }, { tonic: 0, scale: 'major' });
});

t('a profile rotated to G detects G major', () => {
const r = K._detectKeyPure(rotate(K._KK_MAJOR_PROFILE, 7));
assert.deepStrictEqual({ tonic: r.tonic, scale: r.scale }, { tonic: 7, scale: 'major' });
});

t('the minor profile rotated to A detects A minor', () => {
const r = K._detectKeyPure(rotate(K._KK_MINOR_PROFILE, 9));
assert.deepStrictEqual({ tonic: r.tonic, scale: r.scale }, { tonic: 9, scale: 'minor' });
});

// ── a hand-built, realistic distribution ─────────────────────────────

t('a textbook C-major distribution (tonic/dominant/mediant heavy, no chromatics)', () => {
// C C# D D# E F F# G G# A A# B
const w = [10, 0, 5, 0, 7, 4, 0, 8, 0, 5, 0, 4];
const r = K._detectKeyPure(w);
assert.deepStrictEqual({ tonic: r.tonic, scale: r.scale }, { tonic: 0, scale: 'major' });
});

t('a D-heavy dorian-ish set still lands on a sensible tonic (uses the weights)', () => {
// Shift the same shape up two semitones → the detected tonic must move.
const cMaj = [10, 0, 5, 0, 7, 4, 0, 8, 0, 5, 0, 4];
const shifted = rotate(cMaj, 2); // everything up a whole tone
const r0 = K._detectKeyPure(cMaj);
const r2 = K._detectKeyPure(shifted);
assert.notStrictEqual(r0.tonic, r2.tonic, 'shifting the histogram shifts the detected tonic');
assert.strictEqual((r0.tonic + 2) % 12, r2.tonic, 'by exactly the shift amount');
});

// ── degenerate inputs → null (caller shows nothing) ──────────────────

t('empty / all-zero / short / non-array → null', () => {
assert.strictEqual(K._detectKeyPure(new Array(12).fill(0)), null);
assert.strictEqual(K._detectKeyPure([]), null);
assert.strictEqual(K._detectKeyPure(null), null);
assert.strictEqual(K._detectKeyPure([1, 2, 3]), null, 'fewer than 12 bins');
});

t('a single pitch class never throws and returns a key', () => {
const w = new Array(12).fill(0); w[0] = 5;
const r = K._detectKeyPure(w);
assert.ok(r && Number.isInteger(r.tonic), 'a lone C still yields a best-fit key');
});

t('NaN / negative bins are ignored, not counted', () => {
const w = new Array(12).fill(0);
w[0] = NaN; w[4] = -5;
assert.strictEqual(K._detectKeyPure(w), null, 'no positive weight anywhere → null');
});

console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
Loading