mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +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;
|
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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
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 */
|
/** 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user