diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index d7eca82..84d643a 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -10,6 +10,7 @@ import { register } from 'node:module' import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, join } from 'node:path' +import { existsSync } from 'node:fs' // match.js imports './theory' extensionless (resolved by Vite at build time, but // raw Node ESM requires the extension). Register a tiny resolve hook that retries @@ -517,8 +518,10 @@ check("every KB progression 'level', when present, is foundation|intermediate", }) // Same guard for any licks already shipped in the KB: run the REAL packs' -// licks (if any) through checkLick — the registry and the validator must agree. -check('every KB pack licks[] entry (if any) passes checkLick', () => { +// licks (if any) through the instrument-routed checker — piano licks are +// degree-based (checkPianoLick, §5c below), everything else is tab-based +// checkLick — mirroring the validator's own routing. +check('every KB pack licks[] entry (if any) passes its instrument\'s lick check', () => { const ids = new Set() for (const styleName of styleNames) { const instruments = kb[styleName]?.instruments ?? {} @@ -526,8 +529,9 @@ check('every KB pack licks[] entry (if any) passes checkLick', () => { if (pack?.licks === undefined) continue assert(Array.isArray(pack.licks) && pack.licks.length, `${styleName}/${inst}: licks, when present, must be a non-empty array`) + const checkInstLick = inst === 'piano' ? kbv.checkPianoLick : checkLick for (const lick of pack.licks) { - const errs = checkLick(`${styleName}/${inst} ${lick?.id ?? '?'}`, lick, styleName, ids) + const errs = checkInstLick(`${styleName}/${inst} ${lick?.id ?? '?'}`, lick, styleName, ids) assert(errs.length === 0, errs.join('; ')) } } @@ -770,6 +774,171 @@ check('every KB bass pack play passes checkBassPlay', () => { console.log(` (${bassPlays} live bass plays checked)`) }) +// ─── 5c. Piano lick schema (C-60) ───────────────────────────────────────────── +// +// Same lib-mode pattern as §4/§5/§5b: exercise the exported checkPianoLick +// against in-memory fixtures — a realistic enclosure lick must pass, and each +// malformed variant must FAIL with the specific error — proving the piano-lick +// rules bite before any piano licks (P-60) are authored against them. + +console.log('\nPiano lick schema (validate-kb lib mode):') + +const { checkPianoLick, PIANO_LICK_TECHNIQUES, PIANO_LICK_APPROACHES, PIANO_LICK_MAX_OFFSET } = kbv + +check('validate-kb exports checkPianoLick / PIANO_LICK_TECHNIQUES / PIANO_LICK_APPROACHES / PIANO_LICK_MAX_OFFSET(=25)', () => { + assert(typeof checkPianoLick === 'function', 'checkPianoLick is not a function') + assert(Array.isArray(PIANO_LICK_TECHNIQUES) + && PIANO_LICK_TECHNIQUES.join(',') === 'slide,double-stop,ghost-note,grace-note', + `PIANO_LICK_TECHNIQUES must be exactly [slide, double-stop, ghost-note, grace-note], got ${JSON.stringify(PIANO_LICK_TECHNIQUES)}`) + assert(Array.isArray(PIANO_LICK_APPROACHES) && PIANO_LICK_APPROACHES.join(',') === 'chrom-below,chrom-above', + `PIANO_LICK_APPROACHES must be exactly [chrom-below, chrom-above] (no fifth-of-next — licks have no next station), got ${JSON.stringify(PIANO_LICK_APPROACHES)}`) + assert(PIANO_LICK_MAX_OFFSET === 25, + `PIANO_LICK_MAX_OFFSET must be 25 (root in the bottom octave: 11 + 25 = 36, MiniPiano's top key), got ${PIANO_LICK_MAX_OFFSET}`) +}) + +// Vocabulary-family consistency: every piano word except the piano-specific +// 'grace-note' must also be a guitar lick word WITH THE SAME SPELLING — one +// vocabulary family, not a third counting scheme. (The guitar set-equality +// guard in §7b is untouched: LICK_TECHNIQUES itself did not change.) +check("piano vocab ⊂ guitar vocab + 'grace-note' (shared words, one spelling)", () => { + const guitar = new Set(LICK_TECHNIQUES) + const strays = PIANO_LICK_TECHNIQUES.filter((t) => t !== 'grace-note' && !guitar.has(t)) + assert(strays.length === 0, + `piano technique word(s) [${strays}] are neither 'grace-note' nor in LICK_TECHNIQUES — shared words must keep the guitar spelling`) + assert(!guitar.has('grace-note'), + "guitar vocab now contains 'grace-note' — it was piano-specific; update SCHEMA + this guard deliberately") +}) + +// A realistic, fully-valid fixture: a bebop enclosure into the 3rd over min7. +// Offsets: '5'@1 → 19; approaches target '3'@1 → 15, deriving 16 and 14; +// final deg '3'@1 → 15. All in [0, 25]; beats non-decreasing. +const goodPianoLick = () => ({ + id: 'jazz-enclosure-into-3', + name: 'Bebop enclosure into the 3rd', + level: 'intermediate', + chordContext: 'over the ii7', + quality: 'min7', + techniques: ['grace-note'], + source: 'Barry Harris workshop vocabulary', + notes: [ + { deg: '5', octave: 1, beat: 1 }, + { approach: 'chrom-above', beat: 2 }, + { approach: 'chrom-below', beat: 2.5 }, + { deg: '3', octave: 1, beat: 3, technique: 'grace-note' }, + ], +}) + +check('good enclosure fixture PASSES checkPianoLick (0 errors)', () => { + const errs = checkPianoLick('fixture', goodPianoLick(), 'jazz', new Set()) + assert(errs.length === 0, `expected clean pass, got: ${errs.join('; ')}`) +}) + +check("missing / unknown quality FAILS (degrees need a machine context, chordContext is prose)", () => { + const noQ = goodPianoLick(); delete noQ.quality + assert(checkPianoLick('fixture', noQ, 'jazz', new Set()).some((e) => e.includes('quality must be a CHORD_TYPES key')), + 'missing quality accepted') + const badQ = goodPianoLick(); badQ.quality = 'minor7' // not a CHORD_TYPES key + assert(checkPianoLick('fixture', badQ, 'jazz', new Set()).some((e) => e.includes('quality must be a CHORD_TYPES key')), + "quality 'minor7' accepted") +}) + +check("unresolvable degree FAILS ('7' resolves on min7 but '2' never does)", () => { + const lick = goodPianoLick() + lick.notes[0] = { deg: '2', beat: 1 } + const errs = checkPianoLick('fixture', lick, 'jazz', new Set()) + assert(errs.some((e) => e.includes("unresolvable degree '2'")), `deg '2' accepted: ${errs.join('; ')}`) +}) + +check('terminal approach FAILS (targets the next deg — the final note must be a deg)', () => { + const lick = goodPianoLick() + lick.notes.push({ approach: 'chrom-below', beat: 4 }) + const errs = checkPianoLick('fixture', lick, 'jazz', new Set()) + assert(errs.some((e) => e.includes('approach cannot close a piano lick')), `terminal approach accepted: ${errs.join('; ')}`) +}) + +check('consecutive SAME-type approaches FAIL (identical derived pitch); the enclosure (alternating) passes', () => { + const lick = goodPianoLick() + lick.notes[2] = { approach: 'chrom-above', beat: 2.5 } // above, above + const errs = checkPianoLick('fixture', lick, 'jazz', new Set()) + assert(errs.some((e) => e.includes('consecutive')), `same-type approach pair accepted: ${errs.join('; ')}`) + // and the alternating original stays clean (already asserted above, but the contrast is the point) + assert(checkPianoLick('fixture', goodPianoLick(), 'jazz', new Set()).length === 0, 'alternating enclosure rejected') +}) + +check('chrom-below of a root-position target FAILS (derives −1, below the window)', () => { + const lick = goodPianoLick() + lick.notes = [{ approach: 'chrom-below', beat: 1 }, { deg: '1', beat: 2 }] + const errs = checkPianoLick('fixture', lick, 'jazz', new Set()) + assert(errs.some((e) => e.includes('derives −1')), `sub-window approach accepted: ${errs.join('; ')}`) +}) + +check("range cap bites both ways: deg '5' octave 2 (=31) and chrom-above of a 25-offset target (=26) FAIL", () => { + const lick = goodPianoLick() + lick.notes[0] = { deg: '5', octave: 2, beat: 1 } // 7 + 24 = 31 > 25 + const errs = checkPianoLick('fixture', lick, 'jazz', new Set()) + assert(errs.some((e) => e.includes(`max ${PIANO_LICK_MAX_OFFSET}`)), `31-semitone deg accepted: ${errs.join('; ')}`) + const lick2 = goodPianoLick() // b9 @ octave 2 = 25 (legal boundary); chrom-above derives 26 + lick2.quality = 'dom7' + lick2.notes = [{ approach: 'chrom-above', beat: 1 }, { deg: 'b9', octave: 2, beat: 2 }] + const errs2 = checkPianoLick('fixture', lick2, 'jazz', new Set()) + assert(errs2.some((e) => e.includes('derived pitch sits 26')), `26-semitone derived approach accepted: ${errs2.join('; ')}`) + const lick3 = goodPianoLick() // the 25 boundary itself is legal: high root via octave 2 + b9… use deg '1' octave 2 = 24 and chrom-above = 25 + lick3.notes = [{ approach: 'chrom-above', beat: 1 }, { deg: '1', octave: 2, beat: 2 }] + assert(checkPianoLick('fixture', lick3, 'jazz', new Set()).length === 0, + 'the 25-semitone boundary (chrom-above of the double-octave root) was rejected — cap off by one') +}) + +check('bad octave values FAIL (3 and non-integers rejected; approaches take no octave)', () => { + const lick = goodPianoLick() + lick.notes[0] = { deg: '5', octave: 3, beat: 1 } + assert(checkPianoLick('fixture', lick, 'jazz', new Set()).some((e) => e.includes('octave, when present, must be 0, 1 or 2')), + 'octave 3 accepted') + const lick2 = goodPianoLick() + lick2.notes[1] = { approach: 'chrom-above', octave: 1, beat: 2 } + assert(checkPianoLick('fixture', lick2, 'jazz', new Set()).some((e) => e.includes('octave applies to deg notes only')), + 'octave on an approach accepted') +}) + +check("guitar-only technique words FAIL on piano ('bend' in summary; 'vibrato' per-note; summary honesty)", () => { + const lick = goodPianoLick() + lick.techniques = ['grace-note', 'bend'] + assert(checkPianoLick('fixture', lick, 'jazz', new Set()).some((e) => e.includes("unknown piano technique 'bend'")), + "'bend' accepted on keys") + const lick2 = goodPianoLick() + lick2.notes[3].technique = 'vibrato' + assert(checkPianoLick('fixture', lick2, 'jazz', new Set()).some((e) => e.includes("unknown piano technique 'vibrato'")), + "'vibrato' accepted on keys") + const lick3 = goodPianoLick() + lick3.notes[3].technique = 'slide' // valid word, but not in techniques: [grace-note] + assert(checkPianoLick('fixture', lick3, 'jazz', new Set()).some((e) => e.includes("must also appear in the lick's techniques[]")), + 'summary honesty not enforced') +}) + +check('beat range + ordering bite (beat 9; decreasing beats); density cap bites (17 notes)', () => { + const lick = goodPianoLick() + lick.notes[3].beat = 9 + assert(checkPianoLick('fixture', lick, 'jazz', new Set()).some((e) => e.includes('beat must be a number in [1, 9)')), + 'beat 9 accepted') + const lick2 = goodPianoLick() + lick2.notes[3].beat = 2.25 // after 2.5 — decreasing + assert(checkPianoLick('fixture', lick2, 'jazz', new Set()).some((e) => e.includes('beats must be non-decreasing')), + 'decreasing beats accepted') + const lick3 = goodPianoLick() + lick3.notes = Array.from({ length: 17 }, () => ({ deg: '1' })) + assert(checkPianoLick('fixture', lick3, 'jazz', new Set()).some((e) => e.includes('17 notes > 16')), + '17-note lick accepted') +}) + +check('duplicate id FAILS (shared namespace with progressions and guitar licks)', () => { + const ids = new Set() + assert(checkPianoLick('fixture', goodPianoLick(), 'jazz', ids).length === 0, 'first insert should pass') + const errs = checkPianoLick('fixture', goodPianoLick(), 'jazz', ids) + assert(errs.some((e) => e.includes('duplicate id')), `duplicate id accepted: ${errs.join('; ')}`) +}) + +// (The PianoLickCard vocab drift guard lives in §7b-piano below — it needs the +// jsx load hook, which is registered in §7.) + // ─── 6. Loop-detection truth fixtures (C-30) ────────────────────────────────── // // Run every scripts/loop-fixtures.mjs case against the REAL @@ -1005,6 +1174,25 @@ check('TECHNIQUE_VOCAB (LickCard.jsx) ≡ LICK_TECHNIQUES (validate-kb.mjs) as s `technique vocab drift — in validator but not LickCard: [${missingInCard}] · in LickCard but not validator: [${missingInSchema}] — the two lists are hand-synced; add the word to BOTH or neither`) }) +// -- (b-piano) piano technique vocab set-equality (C-60 forward guard) ---------- +// Once D-60's PianoLickCard.jsx lands, its exported PIANO_TECHNIQUE_VOCAB must +// be set-equal to the validator's PIANO_LICK_TECHNIQUES — the same hand-sync +// rule as LickCard. Conditional so smoke stays green until the component +// exists, then bites automatically (D-60's DoD includes making this pass). +if (existsSync(join(ROOT, 'src/components/PianoLickCard.jsx'))) { + const pianoCardMod = await load('src/components/PianoLickCard.jsx') + check('PIANO_TECHNIQUE_VOCAB (PianoLickCard.jsx) ≡ PIANO_LICK_TECHNIQUES (validate-kb.mjs) as sets', () => { + const vocab = new Set(pianoCardMod.PIANO_TECHNIQUE_VOCAB ?? []) + const schema = new Set(PIANO_LICK_TECHNIQUES) + const missingInCard = [...schema].filter((t) => !vocab.has(t)) + const missingInSchema = [...vocab].filter((t) => !schema.has(t)) + assert(missingInCard.length === 0 && missingInSchema.length === 0, + `piano technique vocab drift — in validator but not PianoLickCard: [${missingInCard}] · in PianoLickCard but not validator: [${missingInSchema}] — hand-synced; add the word to BOTH or neither`) + }) +} else { + warn('PianoLickCard vocab drift guard', 'src/components/PianoLickCard.jsx not yet authored (D-60)') +} + // ─── 8. Summary + exit code ─────────────────────────────────────────────────── const total = passed + failures.length diff --git a/scripts/validate-kb.mjs b/scripts/validate-kb.mjs index fa2297a..23675b2 100644 --- a/scripts/validate-kb.mjs +++ b/scripts/validate-kb.mjs @@ -3,9 +3,11 @@ // // Lib mode: scripts/smoke.mjs imports this file with KB_VALIDATE_AS_LIB=1 set to // reuse the exported pure checks (checkLick, checkPianoRecipe, checkBassPlay, -// LEVELS, LICK_TECHNIQUES, MAX_HAND_SPAN, MIN_PLAYS_BASS, BASS_APPROACHES, -// BASS_MAX_OFFSET) against in-memory fixtures — same logic, no copy. When the -// env var is absent the script runs the full KB validation as before. +// checkPianoLick, LEVELS, LICK_TECHNIQUES, PIANO_LICK_TECHNIQUES, +// PIANO_LICK_APPROACHES, PIANO_LICK_MAX_OFFSET, MAX_HAND_SPAN, MIN_PLAYS_BASS, +// BASS_APPROACHES, BASS_MAX_OFFSET) against in-memory fixtures — same logic, +// no copy. When the env var is absent the script runs the full KB validation +// as before. import { readdirSync, existsSync } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, join } from 'node:path' @@ -272,6 +274,130 @@ export function checkLick(where, lick, style, seenIds) { return out } +// ── Piano licks (SCHEMA.md "Piano licks") ──────────────────────────────────── +// Degree-based melodic phrases over ONE explicit quality (guitar tab is +// instrument-truth and needs no quality; degrees need a context to resolve +// through — chordContext stays the human sentence, `quality` is the machine +// truth). Approaches are typed and DERIVED: each targets the next deg note in +// the lick (there is no "next station" inside a self-contained lick), so the +// validator can allow the non-chord tone without blessing arbitrary +// chromatics — and an approach can never close a lick (nothing to target). +// +// Piano technique vocabulary — deliberately NOT LICK_TECHNIQUES: keys don't +// bend, hammer, pull off, or sustain vibrato; chromatic-approach is redundant +// (approaches are typed notes here). The three shared words keep their +// guitar-lick meanings; grace-note (the crushed blues/gospel ornament) is +// piano-specific. smoke.mjs guards both lists' consistency. +export const PIANO_LICK_TECHNIQUES = ['slide', 'double-stop', 'ghost-note', 'grace-note'] +export const PIANO_LICK_APPROACHES = ['chrom-below', 'chrom-above'] +// Range cap on every RESOLVED offset (deg: pc + 12·octave; approach: derived): +// [0, 25] semitones above the root. Proof against MiniPiano's render window +// (absolute notes [0, 36], 0 = low C): place the root at its pitch class in +// the bottom octave (0–11); the highest possible note is then 11 + 25 = 36 — +// exactly the window's top key — so every legal lick fits in all 12 keys. +export const PIANO_LICK_MAX_OFFSET = 25 +const PIANO_LICK_MAX_NOTES = 16 // 8ths over the 2-bar beat window (rule 3) +const PIANO_LICK_MAX_BEAT = 9 // exclusive: 1 ≤ beat < 9 (two 4/4 bars) + +// Pure piano-lick validation. Returns an array of where-prefixed error strings +// (empty = valid); mutates seenIds like checkLick (shared global id +// namespace). Exported for reuse by scripts/smoke.mjs (lib mode). +export function checkPianoLick(where, lick, style, seenIds) { + const out = [] + const e = (msg) => out.push(`${where}: ${msg}`) + if (!lick || typeof lick !== 'object') { e('lick must be an object'); return out } + if (typeof lick.id !== 'string' || !lick.id.startsWith(`${style}-`)) + e(`id must be a string starting with '${style}-'`) + else if (seenIds.has(lick.id)) e(`duplicate id '${lick.id}' (ids are global across progressions AND licks)`) + else seenIds.add(lick.id) + if (!lick.name) e('name missing') + if (!LEVELS.includes(lick.level)) e(`level must be one of ${LEVELS.join(' | ')}, got '${lick.level}'`) + if (typeof lick.chordContext !== 'string' || !lick.chordContext) + e("chordContext missing (which chord/station the lick fits, e.g. 'over the ii7')") + const quality = lick.quality + if (!CHORD_TYPES[quality]) { + e(`quality must be a CHORD_TYPES key (the context every deg resolves through), got '${quality}'`) + return out // nothing below is checkable without a quality + } + const summary = new Set() + if (!Array.isArray(lick.techniques)) e('techniques must be an array (may be empty for a plain lick)') + else for (const t of lick.techniques) { + if (!PIANO_LICK_TECHNIQUES.includes(t)) + e(`unknown piano technique '${t}' — allowed: ${PIANO_LICK_TECHNIQUES.join(', ')}`) + summary.add(t) + } + const notes = lick.notes + if (!Array.isArray(notes) || !notes.length) { e('notes must be a non-empty ordered array'); return out } + if (notes.length > PIANO_LICK_MAX_NOTES) + e(`${notes.length} notes > ${PIANO_LICK_MAX_NOTES} (8ths over two bars) — not intermediate-friendly`) + + // Pass 1: resolved offset of every deg note (null = unresolvable/malformed), + // so approaches can look up their target (the NEXT deg note in order). + const degOffsets = notes.map((n) => { + if (!n || typeof n !== 'object' || n.deg === undefined || typeof n.deg !== 'string') return null + const pc = resolveDegree(n.deg, quality) + if (pc === null) return null + const oct = n.octave === undefined ? 0 : n.octave + return oct === 0 || oct === 1 || oct === 2 ? pc + 12 * oct : null + }) + + let lastBeat = -Infinity + let prevApproach = null // type of the immediately preceding approach note + notes.forEach((n, ni) => { + const nw = `notes[${ni}]` + if (!n || typeof n !== 'object') return e(`${nw}: note must be an object ({deg,…} or {approach,…})`) + const isDeg = n.deg !== undefined + const isApproach = n.approach !== undefined + if (isDeg === isApproach) return e(`${nw}: exactly one of deg | approach per note`) + if (isApproach) { + if (!PIANO_LICK_APPROACHES.includes(n.approach)) + e(`${nw}: unknown approach '${n.approach}' — allowed: ${PIANO_LICK_APPROACHES.join(', ')} (piano licks have no next station; 'fifth-of-next' is bass-only)`) + if (n.octave !== undefined) + e(`${nw}: octave applies to deg notes only (approach pitch is derived from its target)`) + if (prevApproach === n.approach) + e(`${nw}: two consecutive '${n.approach}' approaches derive the identical pitch — write the note you mean as a deg, or alternate types (the enclosure)`) + // Target = the NEXT deg note in order (scan past intervening approaches). + const ti = notes.findIndex((m, i) => i > ni && m?.deg !== undefined) + if (ti === -1) { + e(`${nw}: approach cannot close a piano lick — it targets the NEXT deg note (the final note must be a deg)`) + } else if (degOffsets[ti] !== null) { + const derived = degOffsets[ti] + (n.approach === 'chrom-below' ? -1 : 1) + if (derived < 0) + e(`${nw}: chrom-below of a root-position target derives −1 — below the render window; raise the target an octave`) + else if (derived > PIANO_LICK_MAX_OFFSET) + e(`${nw}: derived pitch sits ${derived} semitones above the root — max ${PIANO_LICK_MAX_OFFSET} (fits MiniPiano's 37-key window for all 12 roots)`) + } // target exists but is itself malformed → its own error already reports it + prevApproach = n.approach + } else { + prevApproach = null + if (typeof n.deg !== 'string') { + e(`${nw}: deg must be a degree STRING ('1', 'b7', …), got ${JSON.stringify(n.deg)}`) + } else { + const pc = resolveDegree(n.deg, quality) + if (pc === null) e(`${nw}: unresolvable degree '${n.deg}' for ${quality}`) + if (n.octave !== undefined && n.octave !== 0 && n.octave !== 1 && n.octave !== 2) + e(`${nw}: octave, when present, must be 0, 1 or 2 — got ${JSON.stringify(n.octave)}`) + else if (pc !== null && degOffsets[ni] !== null && degOffsets[ni] > PIANO_LICK_MAX_OFFSET) + e(`${nw}: '${n.deg}' octave ${n.octave} sits ${degOffsets[ni]} semitones above the root — max ${PIANO_LICK_MAX_OFFSET} (fits MiniPiano's 37-key window for all 12 roots)`) + } + } + if (n.technique !== undefined) { + if (!PIANO_LICK_TECHNIQUES.includes(n.technique)) + e(`${nw}: unknown piano technique '${n.technique}' — allowed: ${PIANO_LICK_TECHNIQUES.join(', ')}`) + else if (!summary.has(n.technique)) + e(`${nw}: technique '${n.technique}' must also appear in the lick's techniques[] summary`) + } + if (n.beat !== undefined) { + if (typeof n.beat !== 'number' || !(n.beat >= 1 && n.beat < PIANO_LICK_MAX_BEAT)) + e(`${nw}: beat must be a number in [1, ${PIANO_LICK_MAX_BEAT}) — a lick spans at most two 4/4 bars, got ${JSON.stringify(n.beat)}`) + else if (n.beat < lastBeat) + e(`${nw}: beat ${n.beat} < previous beat ${lastBeat} — beats must be non-decreasing in note order`) + else lastBeat = n.beat + } + }) + return out +} + async function loadModule(path) { return (await import(pathToFileURL(path).href)).default } @@ -354,15 +480,18 @@ for (const style of styleDirs) { }) } - // Optional structured licks (SCHEMA.md "Licks") — a top-level `licks` key - // on the instrument pack. Absent is fine; when present it must validate. + // Optional structured licks (SCHEMA.md "Licks" / "Piano licks") — a + // top-level `licks` key on the instrument pack. Absent is fine; when + // present it must validate. Routed by instrument: piano licks are + // degree-based (checkPianoLick); guitar licks are tab-based (checkLick). if (pack.licks !== undefined) { if (!Array.isArray(pack.licks) || !pack.licks.length) { err(iw, 'licks, when present, must be a non-empty array') } else { + const checkInstLick = inst === 'piano' ? checkPianoLick : checkLick pack.licks.forEach((lick, li) => { const lkw = `${iw} licks[${li}] "${lick?.id ?? '?'}"` - for (const m of checkLick(lkw, lick, style, allIds)) errors.push(m) + for (const m of checkInstLick(lkw, lick, style, allIds)) errors.push(m) }) totals.licks += pack.licks.length } diff --git a/src/data/kb/SCHEMA.md b/src/data/kb/SCHEMA.md index be79e83..ba861b9 100644 --- a/src/data/kb/SCHEMA.md +++ b/src/data/kb/SCHEMA.md @@ -196,11 +196,12 @@ pack exists — a style without one is complete and valid): filler. A second play is welcome where a genuinely different lane exists (two-feel vs walking, say) — the validator sets a floor, not a ceiling. -## Licks (optional, guitar first) +## Licks (optional) A style's instrument pack may also teach short, named licks — the ordered-note phrases the Licks & Techniques cards render. **The whole section is optional**: -a pack without licks is complete and valid. +a pack without licks is complete and valid. Guitar licks are tab-based (this +section); piano licks are degree-based (next section). **How to register licks:** add a `licks` array as one more top-level key on the instrument pack's default export (next to `styleIntro`/`comping`/`plays`/`improv` @@ -266,6 +267,80 @@ over, source}`) is unchanged and still welcome — it feeds the improv text section. This top-level `licks` array is the *structured* shape that the lick cards render and the validator checks. +## Piano licks (optional, degree-based) + +A piano pack's `licks` array uses the **degree language**, not tab — the same +reasoning as bass patterns (hard rule 1): a lick renders over a *detected* +chord in the *detected* key, so the data must transpose automatically, and a +degree either resolves through the stated quality or it doesn't — you cannot +misspell a pitch, only mislabel your intent. Registration is identical to +guitar licks: a top-level `licks` key on `