mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
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:
@@ -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<PhraseSheetMusicProps> = ({
|
||||
ticks,
|
||||
isAttack,
|
||||
isEighth: code === '8' && !rest,
|
||||
isSixteenth: code === '16' && !rest,
|
||||
staveKey,
|
||||
rest,
|
||||
});
|
||||
@@ -319,12 +323,15 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
|
||||
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) {
|
||||
|
||||
@@ -80,6 +80,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
// (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<PhraseSettings>) => {
|
||||
updateSettings(s => {
|
||||
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
|
||||
@@ -440,6 +444,22 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -548,9 +568,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</select>
|
||||
</label>
|
||||
<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={2}>Mixed</option>
|
||||
<option value={3}>Elaborate (16ths)</option>
|
||||
</select>
|
||||
</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>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Octave
|
||||
<label>Transpose
|
||||
<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={1}>+1</option>
|
||||
<option value={1}>+1 oct</option>
|
||||
<option value={2}>+2 oct</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
@@ -637,9 +660,20 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
)}
|
||||
|
||||
<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="tempo">With tempo</option>
|
||||
<option value="tempo">Sync to metronome</option>
|
||||
</select>
|
||||
</label>
|
||||
{phrase.pace === 'tempo' && (
|
||||
@@ -662,7 +696,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
checked={phrase.clickSound}
|
||||
onChange={e => setPhrase({ clickSound: e.target.checked })}
|
||||
/>
|
||||
Click sound
|
||||
Separate phrase click (off = use the metronome click)
|
||||
</label>
|
||||
)}
|
||||
<label className="phrase-check">
|
||||
@@ -675,7 +709,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</label>
|
||||
</div>
|
||||
{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">
|
||||
Matching checks pitch, not fingering — several fretboard positions produce the same pitch.
|
||||
|
||||
29
src/music/melodyBarTotal.test.ts
Normal file
29
src/music/melodyBarTotal.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user