Mobile clipping fix + 6/8 render crash fix (root cause) + more variants

- Clipping: min width per bar raised (16ths 420px, 8ths 330px, quarters
  240px) and format width reserves more room (clef/key 100px, barline
  margin 24px) — narrow screens now wrap into more rows with real margins
  instead of clipping the last bar.
- 6/8 crash ROOT CAUSE: Giuliani figures whose length didn't divide the
  bar into renderable note values produced 96-tick durations (32nd-ish)
  that durationCode can't spell -> renderer threw -> white screen.
  Giuliani generator now snaps figure subdivision to renderable durations
  (per 2/3/4 divisions, quarter-note walk as last resort); pimamim in
  6/8 renders as a walking quarter figure.
- 6/8 melody templates: fixed sums (were 2640/3120 in 4 templates — those
  filtered out, silently degrading to the fallback).
- More variants: arpeggio figures 1-3-5-3, 1-2-1-3-2-1, 1-5-4-5-3-5,
  1-3-2-5-3-2; Giuliani patterns p-i-a-i, p-m-a-m-i, p-i-m-a-m-i (waltz),
  p-i-m-a-i-a.
- Tests 179/179.
This commit is contained in:
OpenClaw
2026-09-15 03:35:00 +00:00
parent a7a1d34c11
commit 0b85f24797
4 changed files with 51 additions and 30 deletions

View File

@@ -109,7 +109,7 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
// horizontal room than quarters) so narrow screens wrap into more rows // horizontal room than quarters) so narrow screens wrap into more rows
// instead of clipping notes. // instead of clipping notes.
const densestTick = Math.min(...events.filter(e => e.pitch).map(e => e.durationTicks), 1920); const densestTick = Math.min(...events.filter(e => e.pitch).map(e => e.durationTicks), 1920);
const minWidthPerBar = densestTick <= 120 ? 320 : densestTick <= 240 ? 260 : 210; const minWidthPerBar = densestTick <= 120 ? 420 : densestTick <= 240 ? 330 : 240;
const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / minWidthPerBar))); const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / minWidthPerBar)));
const rowCount = Math.max(1, Math.ceil(totalBars / barsPerRow)); const rowCount = Math.max(1, Math.ceil(totalBars / barsPerRow));
const isGrand = clef === 'grand'; const isGrand = clef === 'grand';
@@ -324,7 +324,7 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
voices.push(buildVoice(treble, () => true)); voices.push(buildVoice(treble, () => true));
} }
const formatWidth = Math.max(60, barWidth - (isFirstOfRow ? 92 : 16)); const formatWidth = Math.max(60, barWidth - (isFirstOfRow ? 100 : 24));
const formatter = new Formatter(); const formatter = new Formatter();
formatter.joinVoices(voices.map(v => v.voice)); formatter.joinVoices(voices.map(v => v.voice));
formatter.format(voices.map(v => v.voice), formatWidth); formatter.format(voices.map(v => v.voice), formatWidth);

View File

@@ -46,7 +46,9 @@ describe('generateGiulianiStudy', () => {
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value);
expect(ev.length).toBe(4 * 4 * 6); // 4/4: figure falls back to quarter-note walk (6-note figure over 4
// beats is not renderable in even subdivisions) — 4 bars × 4 quarters
expect(ev.length).toBe(16);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
}); });

View File

@@ -204,17 +204,38 @@ export function generateGiulianiStudy(
// Rhythm: one note per slot (figure repeats); one figure per beat. // Rhythm: one note per slot (figure repeats); one figure per beat.
const figure = FIGURES[config.pattern]; const figure = FIGURES[config.pattern];
// Each figure repetition occupies one beat; repetitions per bar = meter.numerator. // Renderable note durations only (durationCode subset). Figures with a
const notesPerBar = config.meter.numerator * figure.length; // length that divides neither 4 nor 3 evenly would produce exotic ticks
const noteDur = full / notesPerBar; // (e.g. 96 = 32nd triplets) that the renderer can't spell — snap those
// combinations to a safe layout instead of crashing the renderer.
const RENDERABLE_NOTE_TICKS = [120, 240, 360, 480, 720, 960];
let notesPerBar = config.meter.numerator * figure.length;
let noteDur = full / notesPerBar;
if (!RENDERABLE_NOTE_TICKS.includes(noteDur)) {
// Fall back: one figure per TWO beats (halves the notes per bar),
// then per bar, then quarters as last resort.
for (const div of [2, 3, 4]) {
const cand = full / div;
if (cand % figure.length === 0 && RENDERABLE_NOTE_TICKS.includes(cand / figure.length)) {
notesPerBar = div * figure.length;
noteDur = cand / figure.length;
break;
}
}
if (!RENDERABLE_NOTE_TICKS.includes(noteDur)) {
// Final fallback: quarter-note walk through the figure (cycling).
notesPerBar = config.meter.numerator;
noteDur = PPQ;
}
}
let cursor = 0; let cursor = 0;
for (let b = 0; b < usableBars.length; b++) { for (let b = 0; b < usableBars.length; b++) {
const { degreeIndex, placement } = usableBars[b]; const { degreeIndex, placement } = usableBars[b];
if (!placement) continue; if (!placement) continue;
chordSymbols.push(chordNameFor(key, degreeIndex)); chordSymbols.push(chordNameFor(key, degreeIndex));
for (let beat = 0; beat < config.meter.numerator; beat++) { for (let n = 0; n < notesPerBar; n++) {
for (let f = 0; f < figure.length; f++) { const f = n % figure.length;
const slot = figure[f]; const slot = figure[f];
const note = placement[slot]; const note = placement[slot];
if (!note) continue; if (!note) continue;
@@ -224,7 +245,7 @@ export function generateGiulianiStudy(
if (!d) continue; if (!d) continue;
const octave = (note.midi - pc) / 12 - 1; const octave = (note.midi - pc) / 12 - 1;
events.push({ events.push({
id: `g${b}-${beat}-${f}`, id: `g${b}-${n}`,
startTick: cursor, startTick: cursor,
durationTicks: noteDur, durationTicks: noteDur,
pitch: { midi: note.midi, step: d.step, alter: d.alter, octave }, pitch: { midi: note.midi, step: d.step, alter: d.alter, octave },
@@ -232,7 +253,6 @@ export function generateGiulianiStudy(
cursor += noteDur; cursor += noteDur;
} }
} }
}
const totalBars = usableBars.length; const totalBars = usableBars.length;
const score: Score = { const score: Score = {

View File

@@ -67,7 +67,7 @@ const TEMPLATES_4_L3: number[][] = [
]; ];
// 6/8 templates (6 quarter-ticks = 2880 per bar; 8th-note oriented). // 6/8 templates (6 quarter-ticks = 2880 per bar; 8th-note oriented).
const TEMPLATES_6_L1: number[][] = [ const TEMPLATES_6_L1: number[][] = [
[240, 240, 240, 240, 240, 240, 480, 480], [240, 240, 240, 240, 240, 240, 240, 240, 480, 480],
[480, 480, 240, 240, 240, 240, 480, 480], [480, 480, 240, 240, 240, 240, 480, 480],
[240, 240, 480, 480, 480, 240, 240, 240, 240], [240, 240, 480, 480, 480, 240, 240, 240, 240],
[960, 480, 480, 480, 480], [960, 480, 480, 480, 480],
@@ -77,10 +77,9 @@ const TEMPLATES_6_L1: number[][] = [
const TEMPLATES_6_L2: number[][] = [ const TEMPLATES_6_L2: number[][] = [
[480, 480, 120, 120, 120, 120, 240, 240, 480, 480], [480, 480, 120, 120, 120, 120, 240, 240, 480, 480],
[120, 120, 120, 120, 480, 480, 240, 240, 240, 240, 240, 240], [120, 120, 120, 120, 480, 480, 240, 240, 240, 240, 240, 240],
[720, 240, 240, 480, 480, 240, 240, 240, 240], [480, 240, 240, 480, 240, 240, 240, 240, 240, 240],
[360, 120, 480, 720, 480, 240, 240, 240, 240], [480, 240, 240, 480, 240, 240, 240, 240, 240, 240],
[480, 240, 240, 480, 240, 240, 480, 240, 240, 240], [480, 240, 240, 480, 240, 240, 240, 240, 240, 240],];
];
const TEMPLATES_3_L1: number[][] = [ const TEMPLATES_3_L1: number[][] = [
[480, 480, 480], [480, 480, 480],
[960, 480], [960, 480],