Mobile width fix, 6/8 crash fix, more arpeggio+Giuliani variants

- PhraseSheetMusic: min width per bar scales with notation density
  (16ths 320px, 8ths 260px, quarters 210px) — narrow screens wrap into
  more rows instead of clipping notes off-screen.
- 6/8 crash fixed at the root: melody templatesFor had no 6/8 templates
  (empty pool -> pick on empty array). Added 6/8 template sets for levels
  1/2 plus an empty-pool fallback guard so generators can never pick from
  an empty array again (any meter/level combination stays playable).
- More arpeggio broken-chord figures: 1-3-5-3, 1-2-1-3-2-1, 1-5-4-5-3-5,
  1-3-2-5-3-2.
- More Giuliani patterns: p-i-a-i (wide), p-m-a-m-i, p-i-m-a-m-i (waltz
  feel), p-i-m-a-i-a.
- Tests 179/179.
This commit is contained in:
OpenClaw
2026-09-15 03:04:29 +00:00
parent ffb2776c8c
commit a7a1d34c11
5 changed files with 74 additions and 12 deletions

View File

@@ -105,7 +105,12 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
const events = scoreEvents(score); const events = scoreEvents(score);
const totalBars = score.measures.length; const totalBars = score.measures.length;
const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / 210))); // Min width per bar grows with notation density (eighths/16ths need more
// 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 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';
const rowHeight = isGrand ? 250 : 140; const rowHeight = isGrand ? 250 : 140;

View File

@@ -647,6 +647,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
<option value="1325">1-3-2-5(8)</option> <option value="1325">1-3-2-5(8)</option>
<option value="1535">1-5-3-5(8)</option> <option value="1535">1-5-3-5(8)</option>
<option value="12353">1-2-3-5-3</option> <option value="12353">1-2-3-5-3</option>
<option value="1353">1-3-5-3</option>
<option value="121321">1-2-1-3-2-1</option>
<option value="15453">1-5-4-5-3-5</option>
<option value="132532">1-3-2-5-3-2</option>
</optgroup> </optgroup>
</select> </select>
</label> </label>

View File

@@ -21,7 +21,11 @@ export type ArpeggioPattern =
| '1321' // root-fifth-third-root | '1321' // root-fifth-third-root
| '1325' // root-fifth-third-octave | '1325' // root-fifth-third-octave
| '1535' // root-octave-fifth-octave | '1535' // root-octave-fifth-octave
| '12353'; // root-third-fifth-octave-fifth | '12353' // root-third-fifth-octave-fifth
| '121321' // rolling thirds (root-third-root-fifth-third-root)
| '1353' // root-third-fifth-third
| '15453' // root-octave-fifth-octave-fifth-octave
| '132532';// long broken-chord wave
export type ArpeggioCoverage = 'one-octave' | 'two-octave'; export type ArpeggioCoverage = 'one-octave' | 'two-octave';
/** How the chord per bar is chosen in sequence mode. */ /** How the chord per bar is chosen in sequence mode. */
export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle'; export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle';
@@ -61,6 +65,10 @@ export const PATTERN_LABELS: Record<ArpeggioPattern, string> = {
'1325': '1-3-2-5(8)', '1325': '1-3-2-5(8)',
'1535': '1-5-3-5(8)', '1535': '1-5-3-5(8)',
'12353': '1-2-3-5-3', '12353': '1-2-3-5-3',
'121321': '1-2-1-3-2-1',
'1353': '1-3-5-3',
'15453': '1-5-4-5-3-5',
'132532': '1-3-2-5-3-2',
}; };
/** /**
@@ -77,6 +85,10 @@ const BROKEN_FIGURES: Record<string, number[]> = {
'1325': [0, 2, 1, 3], '1325': [0, 2, 1, 3],
'1535': [0, 3, 2, 3], '1535': [0, 3, 2, 3],
'12353': [0, 1, 2, 3, 2], '12353': [0, 1, 2, 3, 2],
'121321': [0, 1, 0, 2, 1, 0],
'1353': [0, 1, 2, 1],
'15453': [0, 3, 2, 3, 2, 3],
'132532': [0, 2, 1, 3, 2, 1],
}; };
/** pc of a spelled degree (step letter + alter). */ /** pc of a spelled degree (step letter + alter). */

View File

@@ -21,8 +21,12 @@ export type GiulianiPattern =
| 'pima' // Studies 21–40: bass + three upper voices | 'pima' // Studies 21–40: bass + three upper voices
| 'pami' // bass + descending upper voices | 'pami' // bass + descending upper voices
| 'aim' // upper voices first | 'aim' // upper voices first
| 'pimamim'// six-note rolling | 'pimamim' // six-note rolling
| 'pmamim';// asymmetric | 'pmamim' // asymmetric
| 'piai' // bass + octave-third (wide spacing)
| 'pmami' // bass + fifth-octave-third
| 'pimami' // bass-third-fifth-third (waltz-like)
| 'pimaia'; // bass with two upper-voice pairs
export const GIULIANI_PATTERN_LABELS: Record<GiulianiPattern, string> = { export const GIULIANI_PATTERN_LABELS: Record<GiulianiPattern, string> = {
pim: 'p-i-m (3 voices)', pim: 'p-i-m (3 voices)',
@@ -31,6 +35,10 @@ export const GIULIANI_PATTERN_LABELS: Record<GiulianiPattern, string> = {
aim: 'a-i-m', aim: 'a-i-m',
pimamim: 'p-i-m-a-m-i', pimamim: 'p-i-m-a-m-i',
pmamim: 'p-m-a-m-i-m', pmamim: 'p-m-a-m-i-m',
piai: 'p-i-a-i (wide)',
pmami: 'p-m-a-m-i',
pimami: 'p-i-m-a-m-i (waltz)',
pimaia: 'p-i-m-a-i-a',
}; };
/** Figure voice slots per repetition (indices into the cycle [root, third, fifth, octave]). */ /** Figure voice slots per repetition (indices into the cycle [root, third, fifth, octave]). */
@@ -41,6 +49,10 @@ const FIGURES: Record<GiulianiPattern, number[]> = {
aim: [0, 3, 1], aim: [0, 3, 1],
pimamim: [0, 1, 2, 3, 2, 1], pimamim: [0, 1, 2, 3, 2, 1],
pmamim: [0, 2, 3, 2, 1, 2], pmamim: [0, 2, 3, 2, 1, 2],
piai: [0, 1, 3, 1],
pmami: [0, 2, 3, 2, 1],
pimami: [0, 1, 2, 3, 2],
pimaia: [0, 1, 2, 3, 1, 3],
}; };
export interface GiulianiConfig { export interface GiulianiConfig {

View File

@@ -65,6 +65,22 @@ const TEMPLATES_4_L3: number[][] = [
[480, 480, 360, 120, 480], [480, 480, 360, 120, 480],
[720, 120, 120, 120, 120, 480], [720, 120, 120, 120, 120, 480],
]; ];
// 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],
[480, 480, 240, 240, 240, 240, 480, 480],
[240, 240, 480, 480, 480, 240, 240, 240, 240],
[960, 480, 480, 480, 480],
[480, 480, 480, 480, 960],
[1440, 480, 480, 480],
];
const TEMPLATES_6_L2: number[][] = [
[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],
];
const TEMPLATES_3_L1: number[][] = [ const TEMPLATES_3_L1: number[][] = [
[480, 480, 480], [480, 480, 480],
[960, 480], [960, 480],
@@ -87,10 +103,23 @@ const TEMPLATES_3_L3: number[][] = [
]; ];
function templatesFor(meter: { numerator: 3 | 4 | 6 }, level: 1 | 2 | 3): number[][] { function templatesFor(meter: { numerator: 3 | 4 | 6 }, level: 1 | 2 | 3): number[][] {
const base = meter.numerator === 4 let base: number[][];
? (level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3]) if (meter.numerator === 4) {
: (level === 1 ? TEMPLATES_3_L1 : level === 2 ? [...TEMPLATES_3_L1, ...TEMPLATES_3_L2] : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_3_L3]); base = level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3];
return base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ); } else if (meter.numerator === 6) {
base = level === 1 ? TEMPLATES_6_L1 : [...TEMPLATES_6_L1, ...TEMPLATES_6_L2];
} else {
base = level === 1 ? TEMPLATES_3_L1 : level === 2 ? [...TEMPLATES_3_L1, ...TEMPLATES_3_L2] : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_3_L3];
}
const out = base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
// Never return an empty pool — fall back to the closest available level
// so generator.pick on an empty array can never crash the app.
if (out.length === 0) {
const all = [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_6_L1];
const anyFit = all.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
return anyFit.length > 0 ? anyFit : [[meter.numerator * PPQ]];
}
return out;
} }
/** Motion weights by semitone distance (soft preference, contract C3). */ /** Motion weights by semitone distance (soft preference, contract C3). */