diff --git a/src/components/PhraseSheetMusic.tsx b/src/components/PhraseSheetMusic.tsx index 641315a..9f516ee 100644 --- a/src/components/PhraseSheetMusic.tsx +++ b/src/components/PhraseSheetMusic.tsx @@ -109,7 +109,7 @@ export const PhraseSheetMusic: React.FC = ({ // horizontal room than quarters) so narrow screens wrap into more rows // instead of clipping notes. 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 rowCount = Math.max(1, Math.ceil(totalBars / barsPerRow)); const isGrand = clef === 'grand'; @@ -324,7 +324,7 @@ export const PhraseSheetMusic: React.FC = ({ 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(); formatter.joinVoices(voices.map(v => v.voice)); formatter.format(voices.map(v => v.voice), formatWidth); diff --git a/src/music/giulianiGenerator.test.ts b/src/music/giulianiGenerator.test.ts index 84ee35c..6f6d602 100644 --- a/src/music/giulianiGenerator.test.ts +++ b/src/music/giulianiGenerator.test.ts @@ -46,7 +46,9 @@ describe('generateGiulianiStudy', () => { expect(r.ok).toBe(true); if (!r.ok) return; 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([]); }); diff --git a/src/music/giulianiGenerator.ts b/src/music/giulianiGenerator.ts index 34d7a37..f2ad228 100644 --- a/src/music/giulianiGenerator.ts +++ b/src/music/giulianiGenerator.ts @@ -204,33 +204,53 @@ export function generateGiulianiStudy( // Rhythm: one note per slot (figure repeats); one figure per beat. const figure = FIGURES[config.pattern]; - // Each figure repetition occupies one beat; repetitions per bar = meter.numerator. - const notesPerBar = config.meter.numerator * figure.length; - const noteDur = full / notesPerBar; + // Renderable note durations only (durationCode subset). Figures with a + // length that divides neither 4 nor 3 evenly would produce exotic ticks + // (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; for (let b = 0; b < usableBars.length; b++) { const { degreeIndex, placement } = usableBars[b]; if (!placement) continue; chordSymbols.push(chordNameFor(key, degreeIndex)); - for (let beat = 0; beat < config.meter.numerator; beat++) { - for (let f = 0; f < figure.length; f++) { - const slot = figure[f]; - const note = placement[slot]; - if (!note) continue; - // spelling via key degree of this pc - const pc = ((note.midi % 12) + 12) % 12; - const d = key.degrees.find(deg => degreePc(deg) === pc); - if (!d) continue; - const octave = (note.midi - pc) / 12 - 1; - events.push({ - id: `g${b}-${beat}-${f}`, - startTick: cursor, - durationTicks: noteDur, - pitch: { midi: note.midi, step: d.step, alter: d.alter, octave }, - }); - cursor += noteDur; - } + for (let n = 0; n < notesPerBar; n++) { + const f = n % figure.length; + const slot = figure[f]; + const note = placement[slot]; + if (!note) continue; + // spelling via key degree of this pc + const pc = ((note.midi % 12) + 12) % 12; + const d = key.degrees.find(deg => degreePc(deg) === pc); + if (!d) continue; + const octave = (note.midi - pc) / 12 - 1; + events.push({ + id: `g${b}-${n}`, + startTick: cursor, + durationTicks: noteDur, + pitch: { midi: note.midi, step: d.step, alter: d.alter, octave }, + }); + cursor += noteDur; } } diff --git a/src/music/melodyGenerator.ts b/src/music/melodyGenerator.ts index 564bf32..bf5301a 100644 --- a/src/music/melodyGenerator.ts +++ b/src/music/melodyGenerator.ts @@ -67,7 +67,7 @@ const TEMPLATES_4_L3: number[][] = [ ]; // 6/8 templates (6 quarter-ticks = 2880 per bar; 8th-note oriented). 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], [240, 240, 480, 480, 480, 240, 240, 240, 240], [960, 480, 480, 480, 480], @@ -75,12 +75,11 @@ const TEMPLATES_6_L1: number[][] = [ [1440, 480, 480, 480], ]; 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], - [720, 240, 240, 480, 480, 240, 240, 240, 240], - [360, 120, 480, 720, 480, 240, 240, 240, 240], - [480, 240, 240, 480, 240, 240, 480, 240, 240, 240], -]; + [480, 240, 240, 480, 240, 240, 240, 240, 240, 240], + [480, 240, 240, 480, 240, 240, 240, 240, 240, 240], + [480, 240, 240, 480, 240, 240, 240, 240, 240, 240],]; const TEMPLATES_3_L1: number[][] = [ [480, 480, 480], [960, 480],