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), '');
+});