1// The art critic's rubric: the one question set behind every "N% art" on 2// the site. It lives here, in the Worker, because the Worker scores 3// listeners' songs with it (content-jobs.ts), and the deploy uploads worker/ 4// alone; the site (website/src/jev/critic.mjs re-exports it) and 5// tools/critic/ import it from here, so a listener's song and the site's 6// own are judged by exactly the same question. Plain JavaScript with no 7// imports, so Node, Vite and the Worker all run this file as it is. 8// 9// A rubric, not a yes/no. Jev scores six criteria on 0-5 ladders whose 10// steps are absolute, the way a harsh critic would use them: 0 is not a 11// song (three notes, one bar looped), 3 is genuinely good, 5 is among the 12// best ever made in its genre. The art score is the mean over the six, out 13// of 5, so 60% means good across the board, 90% takes peak work nearly 14// everywhere, and 100% is out of reach (Jev's score is a probability- 15// weighted mean, never quite 5). 16// 17// The first critic asked one Noul, "is this a deliberate creative work?", 18// and every song cleared it at 93-96% (2026-09-24): it could tell a song 19// from noise, never one song from another. Changing a criterion or a step 20// makes new scores incomparable with recorded ones; rescore every song 21// (tools/critic/critic.mjs --write --all) and bump RUBRIC. 22export const CRITIC_MODEL = 'jev-1.13.0'; 23export const RUBRIC = 'rubric-1'; 24 25const INSTRUCTIONS = 26 'You are a harsh, experienced music critic scoring one criterion of a piece of music. The state holds its title, ' + 27 'its spec (what it set out to be: intent, which is not achievement), its code in Strudel, a live-coding music ' + 28 'language (judge what the code actually plays), and facts measured from the code. Most amateur tracks land at 1 ' + 29 'or 2 on any criterion. 3 means genuinely good, 4 excellent, and 5 is reserved for the best ever made in the genre. ' + 30 'A few notes, or one bar repeated, is 0 on everything. Criterion: '; 31 32// Each criterion: what it judges, and its steps from 0 to 5. 33export const CRITERIA = { 34 hook: { 35 label: 'Hook', 36 judges: 'a memorable motif (melody, riff, vocal line or rhythm) and whether it earns its returns', 37 levels: [ 38 'none: nothing to remember', 39 'a motif exists but is generic or buried', 40 'a serviceable hook that is forgotten soon after', 41 'a hook that sticks after one listen', 42 'a hook people would hum days later', 43 'an iconic hook, among the best in its genre', 44 ], 45 }, 46 development: { 47 label: 'Development', 48 judges: 'whether the music evolves (variation, new material, changes of texture) or loops the same bars', 49 levels: [ 50 'one pattern looped, or a few notes', 51 'repetitive: the same bars with parts muted in and out', 52 'some variation, but mostly the same few bars', 53 'keeps evolving: sections bring real new material and variation', 54 'rich development: every return is changed, nothing outstays its welcome', 55 'masterful development from start to finish', 56 ], 57 }, 58 structure: { 59 label: 'Structure', 60 judges: 'the arc: tension and release, builds that pay off, and an ending that lands', 61 levels: [ 62 'no shape: it starts and stops', 63 'sections exist but do not build or release', 64 'a standard arc, executed without payoff', 65 'a satisfying arc: builds pay off and it ends well', 66 'an arc that surprises and still feels inevitable', 67 'perfect pacing, peak of its genre', 68 ], 69 }, 70 concept: { 71 label: 'Concept', 72 judges: 'whether the sound serves the subject: does the joke or idea land through the music, not just the words', 73 levels: [ 74 'no idea, or one that has nothing to do with the sound', 75 'the idea is only in the title or the samples', 76 'the idea is present but the music is generic', 77 'the music and the idea reinforce each other', 78 'the concept lands hard: sound, lyrics and form all serve it', 79 'a concept executed so well it redefines the joke', 80 ], 81 }, 82 originality: { 83 label: 'Originality', 84 judges: 'what it brings beyond a template of its genre', 85 levels: [ 86 'a default pattern or a copy', 87 'a genre template with names swapped in', 88 'competent genre work with a few personal touches', 89 'a distinct voice within the genre', 90 'fresh: ideas the genre has not heard often', 91 'genre-defining originality', 92 ], 93 }, 94 craft: { 95 label: 'Craft', 96 judges: 'sound design and mix: balance, parts that do not clash, clean transitions, vocals that sit right', 97 levels: [ 98 'broken: clipping, clashing or inaudible parts', 99 'rough: unbalanced, muddy or harsh', 100 'passable: works, with obvious rough edges', 101 'clean and balanced; everything audible and in place', 102 'polished: professional sound design and mix', 103 'reference-quality production', 104 ], 105 }, 106}; 107 108const TOP = 5; 109 110// Facts measured from the code, so Jev judges what is there, not only 111// what the spec says. 112export function songFacts(code) { 113 const bpm = Number(code.match(/setcps\(\s*([\d.]+)\s*\/\s*60\s*\/\s*\d+\s*\)/)?.[1]) || null; // any beats a bar (ALL GREEN is 5/4) 114 const bars = Math.max(0, ...[...code.matchAll(/\.mask\("<1!(\d+) 0!\d+>"\)/g)].map((m) => Number(m[1]))) || null; 115 const lines = code.split('\n').filter((l) => l.trim() && !l.trim().startsWith('//')).length; 116 // Call sites, not questions: a helper like `plays(part)` asks one noul per part. 117 const kinds = [...new Set([...code.matchAll(/\b(choice|score|noul)\s*\(/g)].map((m) => m[1]))]; 118 const jevs = [...code.matchAll(/\bjev\s*\(\s*\{/g)].length; 119 const jev = !jevs 120 ? 'none' 121 : [ 122 /\bwalk\s*\(/.test(code) ? "Jev walks the song's form and decides when it ends" : null, 123 `${jevs} request${jevs > 1 ? 's' : ''} a section, asking ${kinds.join(', ') || 'nothing'} questions`, 124 ] 125 .filter(Boolean) 126 .join('; '); 127 return { tempo: bpm ? `${bpm} bpm` : 'unknown', barsAsWritten: bars, codeLines: lines, arrangedByJev: jev }; 128} 129 130// The mix as heard (tools/measure, measured.mjs), for the state: what the 131// numbers are, then the numbers. Only the current, unedited song has them. 132// Per section it reads the total only: every part of every section would 133// multiply the prompt for detail the whole-song parts already give. 134const measuredMix = (m) => ({ 135 what: 136 'this song played as written, measured in a browser: integrated loudness and peak in dBFS, in total and for ' + 137 'each part (a part is what Jev levels in the song; "other" is everything else), and the total per section, ' + 138 'keyed by the bars it spans. Quieter than about -40 is inaudible in the mix; the output is limited at -1, so ' + 139 'a peak of -1 means the mix is too hot and is being held down', 140 total: m.total, 141 parts: m.parts, 142 ...(m.sections 143 ? { sections: Object.fromEntries(Object.entries(m.sections).map(([k, v]) => [k, v?.total ?? null])) } 144 : {}), 145}); 146 147// The request body; `spec` is the SPEC.md body without its frontmatter, and 148// `measured` the song's measured mix when it has one. 149export const artRequest = ({ title, spec, code, measured = null }) => ({ 150 model: CRITIC_MODEL, 151 questions: Object.fromEntries( 152 Object.entries(CRITERIA).map(([key, c]) => [ 153 key, 154 { type: 'score', instructions: `${INSTRUCTIONS}${c.label}: ${c.judges}.`, criteria: c.levels }, 155 ]), 156 ), 157 state: { title, spec, code, facts: songFacts(code), ...(measured ? { measuredMix: measuredMix(measured) } : {}) }, 158}); 159 160// From the answers: `art`, 0 to 1, and each criterion's score out of 5; 161// null when any criterion is missing. 162export function artOf(answers) { 163 const criteria = {}; 164 for (const key of Object.keys(CRITERIA)) { 165 const score = answers?.[key]?.score; 166 if (typeof score !== 'number') return null; 167 criteria[key] = Math.round(score * 10) / 10; 168 } 169 const mean = Object.values(criteria).reduce((a, b) => a + b, 0) / Object.keys(criteria).length; 170 return { art: Math.round((mean / TOP) * 100) / 100, criteria }; 171} 172 173// Several verdicts on the same code, as one: the mean `art` (0 to 1, to 174// the hundredth, like one verdict's) and each criterion's mean score out of 175// 5 (to the tenth), plus `artRuns`, every verdict's own art in the order 176// asked. One call's art moves by up to 0.06 on unchanged code (measured 177// 2026-09-25 across 14 songs), so the recorded score is a mean 178// (tools/critic/). 179export function meanVerdict(verdicts) { 180 if (!verdicts.length) throw new Error('meanVerdict: no verdicts'); 181 const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; 182 const criteria = Object.fromEntries( 183 Object.keys(CRITERIA).map((key) => [key, Math.round(mean(verdicts.map((v) => v.criteria[key])) * 10) / 10]), 184 ); 185 const artRuns = verdicts.map((v) => v.art); 186 return { art: Math.round(mean(artRuns) * 100) / 100, criteria, artRuns }; 187}