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
8 changes: 7 additions & 1 deletion web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ <h1>griff <span class="muted">· complement playground</span></h1>
<select id="splitCompare" aria-label="compare with phrase"></select>
</label>
<output id="splitInfo" class="hint" role="status" aria-live="polite"></output>
<output id="splitStats" class="hint phrasestats" aria-label="phrase characteristics"></output>
<div id="splitTags" class="checkgrid tagsread" aria-label="detected tags for this phrase"></div>
<div class="transport">
<button id="splitDownload" type="button" class="primary">⬇ this phrase</button>
Expand Down
64 changes: 64 additions & 0 deletions web/static/phrasestats.js
Original file line number Diff line number Diff line change
@@ -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(' · ');
}
8 changes: 8 additions & 0 deletions web/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---- */
Expand Down
76 changes: 76 additions & 0 deletions web/test/phrasestats.test.js
Original file line number Diff line number Diff line change
@@ -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), '');
});
Loading