diff --git a/web/static/app.js b/web/static/app.js index a0588f61..c8d4cc12 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -15,6 +15,7 @@ import { } from './modes.js'; import { describeTuning } from './tuning.js'; import { compareTracks, phraseOptions } from './pager.js'; +import { phraseStats, formatStats } from './phrasestats.js'; import { defaultChunkId, idMatchesFile } from './idguard.js'; const $ = (id) => document.getElementById(id); @@ -38,7 +39,7 @@ const els = { capSplit: $('capSplit'), splitView: $('splitView'), splitPrev: $('splitPrev'), splitNext: $('splitNext'), splitJump: $('splitJump'), splitCompare: $('splitCompare'), - splitInfo: $('splitInfo'), splitTags: $('splitTags'), + splitInfo: $('splitInfo'), splitStats: $('splitStats'), splitTags: $('splitTags'), splitDownload: $('splitDownload'), splitDownloadEach: $('splitDownloadEach'), splitDownloadAll: $('splitDownloadAll'), // verbose on-page debug log @@ -454,6 +455,11 @@ function renderPhrase() { + (ch.duplicate_of != null ? ` · ≈ phrase ${ch.duplicate_of + 1} (${Math.round(ch.duplicate_share * 100)}% quote)` : ''); + // Per-phrase fact-sheet (#75): register · density · dynamics of the shown + // phrase, computed from its notes — a transferable fingerprint, not a label. + if (els.splitStats) { + els.splitStats.textContent = formatStats(phraseStats(ch.notes || [], ch.bar_hi - ch.bar_lo + 1)); + } renderPhraseTags(tags); els.splitPrev.disabled = splitIdx === 0; els.splitNext.disabled = splitIdx === splitChunks.length - 1; diff --git a/web/static/index.html b/web/static/index.html index 4f146972..6cc4deb3 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -83,6 +83,7 @@

griff · complement playground

+
diff --git a/web/static/phrasestats.js b/web/static/phrasestats.js new file mode 100644 index 00000000..eedb00d6 --- /dev/null +++ b/web/static/phrasestats.js @@ -0,0 +1,64 @@ +// Pure, DOM-free helpers for the per-phrase fact-sheet in the split pager +// (see app.js). Kept apart so the stats logic is unit-tested under `node --test` +// (web/test/phrasestats.test.js) without a browser or the wasm engine. +// +// The fact-sheet reports a phrase's intrinsic, transferable characteristics — +// register, density, dynamics — rather than a song-form label (verse/chorus), +// which is bound to the source song and lost once a phrase enters the corpus +// (#75). Everything is derived from the split's `{p,s,d,v}` notes. + +const PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +// Velocity range at or below which a phrase reads as flat (a hand for the +// `flat_dynamics` quality flag). Coarse on purpose, like the capture triage. +const FLAT_VELOCITY_RANGE = 8; + +// Scientific pitch name for a MIDI note, e.g. 40 → "E2", 69 → "A4", 61 → "C#4". +// MIDI 69 is A4 (A440), so octave = floor(midi / 12) - 1. +export function noteName(midi) { + const m = Math.round(midi); + const pc = ((m % 12) + 12) % 12; + const octave = Math.floor(m / 12) - 1; + return `${PITCH_CLASSES[pc]}${octave}`; +} + +// Summarises a phrase's `{p,s,d,v}` notes over `bars` bars: note count, register +// (low/high MIDI + semitone span), notes-per-bar density (one decimal, null when +// the bar span is unknown), and velocity range + a flat-dynamics flag. Returns +// null for an empty or missing phrase. +export function phraseStats(notes, bars) { + if (!Array.isArray(notes) || notes.length === 0) return null; + let low = Infinity; + let high = -Infinity; + let velLo = Infinity; + let velHi = -Infinity; + for (const n of notes) { + if (n.p < low) low = n.p; + if (n.p > high) high = n.p; + if (n.v < velLo) velLo = n.v; + if (n.v > velHi) velHi = n.v; + } + const perBar = bars > 0 ? Math.round((notes.length / bars) * 10) / 10 : null; + return { + count: notes.length, + low, + high, + span: high - low, + perBar, + velLo, + velHi, + flat: velHi - velLo <= FLAT_VELOCITY_RANGE, + }; +} + +// Renders a phraseStats object as a compact one-liner for the pager, e.g. +// "E2–A4 · 17 st · 6.4 notes/bar · vel 64–120". Empty string for null input. +export function formatStats(st) { + if (!st) return ''; + const parts = [`${noteName(st.low)}–${noteName(st.high)}`, `${st.span} st`]; + if (st.perBar != null) { + parts.push(`${st.perBar} note${st.perBar === 1 ? '' : 's'}/bar`); + } + parts.push(`vel ${st.velLo}–${st.velHi}${st.flat ? ' (flat)' : ''}`); + return parts.join(' · '); +} diff --git a/web/static/style.css b/web/static/style.css index 4d023f40..d2dc9663 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -180,6 +180,14 @@ label.row input[type="checkbox"] { width: 22px; height: 22px; accent-color: var( font-size: 0.9rem; } #splitInfo { display: block; text-align: center; min-height: 1em; } +#splitStats { + display: block; + text-align: center; + min-height: 1em; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.7rem; + color: var(--a); +} button:disabled { opacity: 0.4; cursor: default; } /* ---- debug log (verbose on-page engine trace) ---- */ diff --git a/web/test/phrasestats.test.js b/web/test/phrasestats.test.js new file mode 100644 index 00000000..840bd406 --- /dev/null +++ b/web/test/phrasestats.test.js @@ -0,0 +1,76 @@ +// Unit tests for the per-phrase stats helpers (web/static/phrasestats.js). +// Pure logic, no DOM/wasm — runs under `node --test` (see web/package.json). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { noteName, phraseStats, formatStats } from '../static/phrasestats.js'; + +test('noteName renders scientific pitch names', () => { + assert.equal(noteName(60), 'C4'); // middle C + assert.equal(noteName(69), 'A4'); // A440 + assert.equal(noteName(40), 'E2'); // low-E string + assert.equal(noteName(61), 'C#4'); // a sharp +}); + +test('phraseStats summarises register, density and dynamics', () => { + const notes = [ + { p: 40, s: 0, d: 240, v: 80 }, + { p: 52, s: 240, d: 240, v: 120 }, + { p: 47, s: 480, d: 240, v: 64 }, + { p: 57, s: 720, d: 240, v: 100 }, + ]; + const st = phraseStats(notes, 2); + assert.equal(st.count, 4); + assert.equal(st.low, 40); + assert.equal(st.high, 57); + assert.equal(st.span, 17); + assert.equal(st.perBar, 2); // 4 notes / 2 bars + assert.equal(st.velLo, 64); + assert.equal(st.velHi, 120); + assert.equal(st.flat, false); +}); + +test('phraseStats flags flat dynamics and rounds density to one decimal', () => { + const notes = [ + { p: 50, s: 0, d: 100, v: 100 }, + { p: 50, s: 100, d: 100, v: 103 }, + { p: 50, s: 200, d: 100, v: 101 }, + ]; + const st = phraseStats(notes, 4); + assert.equal(st.span, 0); + assert.equal(st.perBar, 0.8); // 3 / 4 = 0.75 → 0.8 + assert.equal(st.flat, true); // velocity range 3 ≤ threshold +}); + +test('phraseStats returns null for an empty or missing phrase', () => { + assert.equal(phraseStats([], 4), null); + assert.equal(phraseStats(undefined, 4), null); +}); + +test('phraseStats guards a zero-bar span (no divide-by-zero)', () => { + const st = phraseStats([{ p: 60, s: 0, d: 10, v: 90 }], 0); + assert.equal(st.perBar, null); // unknown, not Infinity +}); + +test('formatStats renders a compact one-liner', () => { + const notes = [ + { p: 40, s: 0, d: 240, v: 64 }, + { p: 57, s: 240, d: 240, v: 120 }, + ]; + assert.equal( + formatStats(phraseStats(notes, 2)), + 'E2–A3 · 17 st · 1 note/bar · vel 64–120', // MIDI 57 = A3 (A4 = 69) + ); +}); + +test('formatStats marks flat dynamics and handles empty input', () => { + const flat = phraseStats( + [ + { p: 50, s: 0, d: 1, v: 100 }, + { p: 50, s: 1, d: 1, v: 101 }, + ], + 1, + ); + assert.equal(formatStats(flat), 'D3–D3 · 0 st · 2 notes/bar · vel 100–101 (flat)'); + assert.equal(formatStats(null), ''); +});