diff --git a/PLAN.md b/PLAN.md index e2a7b93..9d4f72d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1529,3 +1529,8 @@ Decided: checks every whole-score example in the guide. The eval still does not judge melodies. Both packages go to 1.0.1 (the plugin's peer to ^1.0.1: its tool description names songs, which the guide of 1.0.1 explains). +- 2026-10-26 — A strum past the end. MulmoChat's Gemini three times failed to fix a ukulele chord + whose "d-u-ud-u" ran past `length`: the error said "Repetition 5 of this note" (the note had no + `repeat`; each stroke counted as one) and listed every stroke after the end, and Gemini kept + changing `len`, which a strum ignores. Now one error per note, naming the stroke of the pattern, + with a hint that the pattern lasts its own beats whatever `len` says. diff --git a/src/events.ts b/src/events.ts index 4715214..91a734b 100644 --- a/src/events.ts +++ b/src/events.ts @@ -359,11 +359,38 @@ function expandNote(note: Note, ctx: NoteContext): { events: NoteEvent[]; issues return noteEvent(note, ctx, pitches, { index, seconds: t + stroke.offset, onCue, stroke, length }); }), ); - for (const event of events) issues.push(...timeIssues(event, ctx.duration, path)); + // One issue per note: the first sound that falls outside the jingle (a strum or a repeat that + // overruns the end would otherwise list every stroke after it). + for (const event of events) { + const found = timeIssues(event, ctx.duration, path, soundOf(note, event.repeat, strokes.length)); + if (found.length > 0) { + issues.push(...found); + break; + } + } return { events, issues }; } -function timeIssues(event: NoteEvent, duration: number, path: IssuePath): Issue[] { - const where = event.repeat === 0 ? "This note" : `Repetition ${event.repeat + 1} of this note`; + +/** Which sound of a note an issue is about (the note, one of its repeats, a stroke of its strum), and what to do when it falls after the end. */ +interface Sound { + where: string; + pastEndHint: string; +} + +function soundOf(note: Note, index: number, strokesPerRepeat: number): Sound { + const repeat = Math.floor(index / strokesPerRepeat); + if (note.strum === undefined || strokesPerRepeat < 2) { + return { where: repeat === 0 ? "This note" : `Repetition ${repeat + 1} of this note`, pastEndHint: "Make `length` longer or move the note earlier." }; + } + const spec = normalizeStrum(note.strum); + const inRepeat = repeat === 0 ? "" : ` in repetition ${repeat + 1}`; + return { + where: `Stroke ${(index % strokesPerRepeat) + 1} of this note's strum pattern "${spec.pattern}"${inRepeat}`, + pastEndHint: `A strum pattern plays one stroke per character whatever the note's \`len\`: "${spec.pattern}" lasts ${strumSlots(spec).beats} beats from the note's \`at\`. Make \`length\` longer, move the note earlier, or end on a shorter pattern ("down" is one stroke).`, + }; +} + +function timeIssues(event: NoteEvent, duration: number, path: IssuePath, { where, pastEndHint }: Sound): Issue[] { if (event.seconds < -EPSILON) { return [ { path: [...path, "at"], message: `${where} starts before 0 s (${event.seconds.toFixed(3)} s).`, hint: "Move it later or reduce the negative offset." }, @@ -374,7 +401,7 @@ function timeIssues(event: NoteEvent, duration: number, path: IssuePath): Issue[ { path: [...path, "at"], message: `${where} starts at ${event.seconds.toFixed(3)} s, at or after the end (length ${duration.toFixed(3)} s).`, - hint: "Make `length` longer or move the note earlier.", + hint: pastEndHint, }, ]; } diff --git a/tests/score.test.ts b/tests/score.test.ts index 6faccf5..41b89aa 100644 --- a/tests/score.test.ts +++ b/tests/score.test.ts @@ -91,6 +91,20 @@ describe("score validation (errors written for repair)", () => { expect(errorsOf({ ...base, cues: { late: { seconds: 5 } } })[0]).toMatchObject({ path: "cues.late" }); }); + it("reports a strum that overruns the end once, by its stroke and how long its pattern plays", () => { + const ukulele = { ...base, tracks: [{ instrument: "ukulele", notes: [{ at: 6, chord: "Am", len: 1, strum: "d-u-ud-u" }] }] }; + const errors = errorsOf(ukulele); + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toContain('Stroke 3 of this note\'s strum pattern "d-u-ud-u"'); + expect(errors[0]?.hint).toContain('"d-u-ud-u" lasts 4 beats'); + }); + + it("reports only the first repetition of a note that runs past the end", () => { + const errors = errorsOf(withNotes([{ at: 0, pitch: "C5", repeat: { every: 1, count: 20 } }])); + expect(errors).toHaveLength(1); + expect(errors[0]?.message).toMatch(/^Repetition \d+ of this note starts at/); + }); + it("requires exactly one of repeat.count and repeat.until", () => { expect(errorsOf(withNotes([{ at: 0, pitch: "C5", repeat: { every: 1 } }]))[0]?.message).toBe("Give exactly one of `count` or `until`."); });