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
5 changes: 5 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 31 additions & 4 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand All @@ -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,
},
];
}
Expand Down
14 changes: 14 additions & 0 deletions tests/score.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.");
});
Expand Down
Loading