Feedback round 3: metronome/phrase integration, 16ths, count-in badge, wider transpose

- Metronome/phrase: 'With tempo' renamed to 'Sync to metronome' (auto-enables
  the metronome when selected); phrase click now yields to the metronome
  click (no more double clicks) with an explicit opt-out checkbox.
- Count-in: subtle numeric badge next to the status line (muted pill, 11px).
- Melody rhythm level 3 'Elaborate': sixteenth/dotted-eighth templates for
  4/4 and 3/4; renderer supports 120/360-tick codes and beams 16th pairs.
- Transpose control for library/import material widened to ±2 octaves.
- Tests 161/161 (bar-total invariants hold over 500 seeds at level 2).
This commit is contained in:
OpenClaw
2026-09-14 16:14:20 +00:00
parent 85fe20bf51
commit 57a6a654a0
5 changed files with 109 additions and 19 deletions

View File

@@ -40,6 +40,7 @@ interface Fragment {
ticks: number; ticks: number;
isAttack: boolean; isAttack: boolean;
isEighth: boolean; isEighth: boolean;
isSixteenth: boolean;
staveKey: 'treble' | 'bass'; staveKey: 'treble' | 'bass';
rest: boolean; rest: boolean;
} }
@@ -47,7 +48,9 @@ interface Fragment {
// Duration ticks → VexFlow duration code (subset the model guarantees). // Duration ticks → VexFlow duration code (subset the model guarantees).
function durationCode(ticks: number): string | null { function durationCode(ticks: number): string | null {
switch (ticks) { switch (ticks) {
case 120: return '16';
case 240: return '8'; case 240: return '8';
case 360: return '8d';
case 480: return '4'; case 480: return '4';
case 720: return '4d'; case 720: return '4d';
case 960: return '2'; case 960: return '2';
@@ -226,6 +229,7 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
ticks, ticks,
isAttack, isAttack,
isEighth: code === '8' && !rest, isEighth: code === '8' && !rest,
isSixteenth: code === '16' && !rest,
staveKey, staveKey,
rest, rest,
}); });
@@ -319,12 +323,15 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
line.setContext(context).draw(); line.setContext(context).draw();
} }
// --- Beams: consecutive eighth pairs within one beat --- // --- Beams: consecutive eighth/16th pairs within one beat ---
const eighths = frags.filter(f => f.isEighth && f.note instanceof StaveNote); const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote);
for (let i = 0; i + 1 < eighths.length; i++) { for (let i = 0; i + 1 < beamed.length; i++) {
const a = eighths[i], b2 = eighths[i + 1]; const a = beamed[i], b2 = beamed[i + 1];
if (a.staveKey !== b2.staveKey) continue; 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 beatA = Math.floor((a.start - measureStart) / 480);
const beatB = Math.floor((b2.start - measureStart) / 480); const beatB = Math.floor((b2.start - measureStart) / 480);
if (beatA === beatB) { if (beatA === beatB) {

View File

@@ -80,6 +80,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
// (single source of truth across modes; the phrase-local BPM control // (single source of truth across modes; the phrase-local BPM control
// was replaced by the common metronome widget in Controls). // was replaced by the common metronome widget in Controls).
phrase.bpm = settings.rhythm.bpm; 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<PhraseSettings>) => { const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
updateSettings(s => { updateSettings(s => {
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
@@ -440,6 +444,22 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>} {rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
<div className="feedback-area"> <div className="feedback-area">
{trainer.phase === 'countIn' && (
<span
className="phrase-countin"
style={{
display: 'inline-block',
minWidth: '20px',
padding: '1px 7px',
marginRight: '8px',
borderRadius: '10px',
fontSize: '11px',
opacity: 0.75,
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
}}
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
>{trainer.countInLeft}</span>
)}
<div className="instruction-text phrase-status">{statusText}</div> <div className="instruction-text phrase-status">{statusText}</div>
</div> </div>
@@ -548,9 +568,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
<label>Rhythm <label>Rhythm
<select value={phrase.rhythmLevel} onChange={e => setPhrase({ rhythmLevel: Number(e.target.value) === 2 ? 2 : 1 })}> <select value={phrase.rhythmLevel} onChange={e => setPhrase({ rhythmLevel: Math.max(1, Math.min(3, Number(e.target.value))) as 1 | 2 | 3 })}>
<option value={1}>Simple</option> <option value={1}>Simple</option>
<option value={2}>Mixed</option> <option value={2}>Mixed</option>
<option value={3}>Elaborate (16ths)</option>
</select> </select>
</label> </label>
</> </>
@@ -605,11 +626,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b}</option>)} {[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b}</option>)}
</select> </select>
</label> </label>
<label>Octave <label>Transpose
<select value={octaveShift} onChange={e => setOctaveShift(Number(e.target.value))}> <select value={octaveShift} onChange={e => setOctaveShift(Number(e.target.value))}>
<option value={-1}>-1</option> <option value={-2}>-2 oct</option>
<option value={-1}>-1 oct</option>
<option value={0}>0</option> <option value={0}>0</option>
<option value={1}>+1</option> <option value={1}>+1 oct</option>
<option value={2}>+2 oct</option>
</select> </select>
</label> </label>
</> </>
@@ -637,9 +660,20 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
)} )}
<label>Pace <label>Pace
<select value={phrase.pace} onChange={e => setPhrase({ pace: e.target.value as PhraseSettings['pace'] })}> <select
value={phrase.pace}
onChange={e => {
const pace = e.target.value as PhraseSettings['pace'];
setPhrase({ pace });
// "Sync to metronome" only makes sense with the
// metronome running — switch it on automatically.
if (pace === 'tempo' && !settings.rhythm.active) {
setSettings(s => ({ ...s, rhythm: { ...s.rhythm, active: true } }));
}
}}
>
<option value="step">At your pace</option> <option value="step">At your pace</option>
<option value="tempo">With tempo</option> <option value="tempo">Sync to metronome</option>
</select> </select>
</label> </label>
{phrase.pace === 'tempo' && ( {phrase.pace === 'tempo' && (
@@ -662,7 +696,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
checked={phrase.clickSound} checked={phrase.clickSound}
onChange={e => setPhrase({ clickSound: e.target.checked })} onChange={e => setPhrase({ clickSound: e.target.checked })}
/> />
Click sound Separate phrase click (off = use the metronome click)
</label> </label>
)} )}
<label className="phrase-check"> <label className="phrase-check">
@@ -675,7 +709,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
</div> </div>
{phrase.pace === 'tempo' && phrase.clickSound && ( {phrase.pace === 'tempo' && phrase.clickSound && (
<p className="phrase-note">Clicks are short noise bursts, not pitched tones — but speaker bleed can still disturb the microphone. Headphones recommended.</p> <p className="phrase-note">Phrase click active — switch the metronome off to avoid double clicks, or untick this to reuse the metronome click.</p>
)} )}
<p className="phrase-note"> <p className="phrase-note">
Matching checks pitch, not fingering — several fretboard positions produce the same pitch. Matching checks pitch, not fingering — several fretboard positions produce the same pitch.

View File

@@ -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);
});
});

View File

@@ -31,8 +31,9 @@ export interface MelodyConfig {
/** 1..8 */ /** 1..8 */
readonly bars: number; readonly bars: number;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 }; readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
/** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters). */ /** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters),
readonly rhythmLevel: 1 | 2; * 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */
readonly rhythmLevel: 1 | 2 | 3;
readonly seed: number; readonly seed: number;
} }
@@ -53,6 +54,17 @@ const TEMPLATES_4_L2: number[][] = [
[720, 720, 480], [720, 720, 480],
[720, 480, 240, 240, 240], [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[][] = [ const TEMPLATES_3_L1: number[][] = [
[480, 480, 480], [480, 480, 480],
[960, 480], [960, 480],
@@ -65,11 +77,19 @@ const TEMPLATES_3_L2: number[][] = [
[720, 720], [720, 720],
[720, 240, 240, 240], [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 const base = meter.numerator === 4
? (level === 1 ? TEMPLATES_4_L1 : [...TEMPLATES_4_L1, ...TEMPLATES_4_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 : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2]); : (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); return base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
} }

View File

@@ -50,7 +50,7 @@ export interface PhraseSettings {
keyMode: string; // ModeId keyMode: string; // ModeId
bars: number; // 1–8 (melodies) bars: number; // 1–8 (melodies)
meterNumerator: 3 | 4; meterNumerator: 3 | 4;
rhythmLevel: 1 | 2; rhythmLevel: 1 | 2 | 3;
scaleCoverage: 'one-octave' | 'two-octave' | 'position'; scaleCoverage: 'one-octave' | 'two-octave' | 'position';
scaleDirection: 'up' | 'down' | 'updown'; scaleDirection: 'up' | 'down' | 'updown';
/** Phrase-local fret window (guitar/bass) — overrides the note set. */ /** Phrase-local fret window (guitar/bass) — overrides the note set. */