diff --git a/src/components/PhraseSheetMusic.tsx b/src/components/PhraseSheetMusic.tsx index ff1e67e..59cdba9 100644 --- a/src/components/PhraseSheetMusic.tsx +++ b/src/components/PhraseSheetMusic.tsx @@ -40,6 +40,7 @@ interface Fragment { ticks: number; isAttack: boolean; isEighth: boolean; + isSixteenth: boolean; staveKey: 'treble' | 'bass'; rest: boolean; } @@ -47,7 +48,9 @@ interface Fragment { // Duration ticks → VexFlow duration code (subset the model guarantees). function durationCode(ticks: number): string | null { switch (ticks) { + case 120: return '16'; case 240: return '8'; + case 360: return '8d'; case 480: return '4'; case 720: return '4d'; case 960: return '2'; @@ -226,6 +229,7 @@ export const PhraseSheetMusic: React.FC = ({ ticks, isAttack, isEighth: code === '8' && !rest, + isSixteenth: code === '16' && !rest, staveKey, rest, }); @@ -319,12 +323,15 @@ export const PhraseSheetMusic: React.FC = ({ line.setContext(context).draw(); } - // --- Beams: consecutive eighth pairs within one beat --- - const eighths = frags.filter(f => f.isEighth && f.note instanceof StaveNote); - for (let i = 0; i + 1 < eighths.length; i++) { - const a = eighths[i], b2 = eighths[i + 1]; + // --- Beams: consecutive eighth/16th pairs within one beat --- + const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote); + for (let i = 0; i + 1 < beamed.length; i++) { + const a = beamed[i], b2 = beamed[i + 1]; if (a.staveKey !== b2.staveKey) continue; - if (b2.start - a.start !== 240) continue; + // consecutive within the same beat, same subdivision size + const step = a.isSixteenth ? 120 : 240; + if (b2.start - a.start !== step) continue; + if ((a.ticks !== step) || (b2.ticks !== step)) continue; const beatA = Math.floor((a.start - measureStart) / 480); const beatB = Math.floor((b2.start - measureStart) / 480); if (beatA === beatB) { diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 5d87cfa..07cf193 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -80,6 +80,10 @@ export const PhraseTrainer = forwardRef( // (single source of truth across modes; the phrase-local BPM control // was replaced by the common metronome widget in Controls). phrase.bpm = settings.rhythm.bpm; + // "Sync to metronome": phrase tempo pace reuses the common metronome + // click — never both (the phrase clickSound is disabled in that case). + const metronomeActive = settings.rhythm.active; + phrase.clickSound = phrase.pace === 'tempo' && phrase.clickSound && !metronomeActive; const setPhrase = useCallback((updates: Partial) => { updateSettings(s => { const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; @@ -440,6 +444,22 @@ export const PhraseTrainer = forwardRef( {rangeWarning &&
{rangeWarning}
}
+ {trainer.phase === 'countIn' && ( + {trainer.countInLeft} + )}
{statusText}
@@ -548,9 +568,10 @@ export const PhraseTrainer = forwardRef( @@ -605,11 +626,13 @@ export const PhraseTrainer = forwardRef( {[1, 2, 4, 6, 8].map(b => )} - )} {phrase.pace === 'tempo' && phrase.clickSound && ( -

Clicks are short noise bursts, not pitched tones — but speaker bleed can still disturb the microphone. Headphones recommended.

+

Phrase click active — switch the metronome off to avoid double clicks, or untick this to reuse the metronome click.

)}

Matching checks pitch, not fingering — several fretboard positions produce the same pitch. diff --git a/src/music/melodyBarTotal.test.ts b/src/music/melodyBarTotal.test.ts new file mode 100644 index 0000000..e303727 --- /dev/null +++ b/src/music/melodyBarTotal.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { generateMelody } from './melodyGenerator'; +import { scoreEvents, validateScore } from '../score/model'; + +// Reproduce user report: "bei 4/4 manchmal 5 Viertel in bar 2" +describe('melody generator bar totals (4/4, level 2)', () => { + const strings = [40, 45, 50, 55, 59, 64]; + const pool: number[] = []; + for (const open of strings) for (let f = 0; f <= 12; f++) pool.push(open + f); + + it('bar 2 always totals exactly one bar', () => { + let checked = 0; + for (let seed = 1; seed <= 500; seed++) { + const r = generateMelody( + { keyTonic: 'C', keyMode: 'major', bars: 2, meter: { numerator: 4, denominator: 4 }, rhythmLevel: 2, seed }, + pool, + ); + if (!r.ok) continue; + checked++; + const ev = scoreEvents(r.value.score); + const problems = validateScore(r.value.score); + expect(problems, `seed ${seed}`).toEqual([]); + const bar2 = ev.filter(e => e.startTick >= 1920 && e.startTick < 3840); + const bar2Len = bar2.reduce((a, e) => a + e.durationTicks, 0); + expect(bar2Len, `seed ${seed} bar2 ticks`).toBe(1920); + } + expect(checked).toBeGreaterThan(400); + }); +}); diff --git a/src/music/melodyGenerator.ts b/src/music/melodyGenerator.ts index 7ab7b42..8b5e5be 100644 --- a/src/music/melodyGenerator.ts +++ b/src/music/melodyGenerator.ts @@ -31,8 +31,9 @@ export interface MelodyConfig { /** 1..8 */ readonly bars: number; readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 }; - /** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters). */ - readonly rhythmLevel: 1 | 2; + /** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters), + * 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */ + readonly rhythmLevel: 1 | 2 | 3; readonly seed: number; } @@ -53,6 +54,17 @@ const TEMPLATES_4_L2: number[][] = [ [720, 720, 480], [720, 480, 240, 240, 240], ]; +// Level 3: sixteenth-note material (120 = 16th, 360 = dotted 8th). +const TEMPLATES_4_L3: number[][] = [ + [480, 480, 120, 120, 120, 120, 480], + [120, 120, 120, 120, 480, 480, 480], + [480, 120, 120, 120, 120, 480, 480], + [360, 120, 480, 480, 480], + [480, 360, 120, 480, 480], + [120, 120, 120, 120, 120, 120, 120, 120, 480, 480], + [480, 480, 360, 120, 480], + [720, 120, 120, 120, 120, 480], +]; const TEMPLATES_3_L1: number[][] = [ [480, 480, 480], [960, 480], @@ -65,11 +77,19 @@ const TEMPLATES_3_L2: number[][] = [ [720, 720], [720, 240, 240, 240], ]; +// Level 3 for 3/4: sixteenth material filling 1440 ticks. +const TEMPLATES_3_L3: number[][] = [ + [480, 120, 120, 120, 120, 480, 480], + [120, 120, 120, 120, 480, 480, 480], + [360, 120, 480, 480, 480], + [480, 360, 120, 480, 480], + [120, 120, 120, 120, 120, 120, 120, 120, 480], +]; -function templatesFor(meter: { numerator: 3 | 4 }, level: 1 | 2): number[][] { +function templatesFor(meter: { numerator: 3 | 4 }, level: 1 | 2 | 3): number[][] { const base = meter.numerator === 4 - ? (level === 1 ? TEMPLATES_4_L1 : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2]) - : (level === 1 ? TEMPLATES_3_L1 : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2]); + ? (level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3]) + : (level === 1 ? TEMPLATES_3_L1 : level === 2 ? [...TEMPLATES_3_L1, ...TEMPLATES_3_L2] : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_3_L3]); return base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ); } diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index f939f95..3f9d8c5 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -50,7 +50,7 @@ export interface PhraseSettings { keyMode: string; // ModeId bars: number; // 1–8 (melodies) meterNumerator: 3 | 4; - rhythmLevel: 1 | 2; + rhythmLevel: 1 | 2 | 3; scaleCoverage: 'one-octave' | 'two-octave' | 'position'; scaleDirection: 'up' | 'down' | 'updown'; /** Phrase-local fret window (guitar/bass) — overrides the note set. */