mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +01:00
Metronome always-visible controls, Giuliani patterns, pool fix
- Metronome widget: BPM/slider row and pendulum always visible (dimmed when off); the toggle gates sound + animation only. BPM/Timer mode switch restored for single-note auto-advance. - Giuliani right-hand figures as arpeggio patterns: p-i-m, p-i-m-a, p-a-m-i, m-a-m-i, a-m-i — cycle [root, third, fifth, octave] per figure with octave register shift per repetition (120-studies feel). - Pool fix: fretted instruments without active filters now get the full 0-12 fretboard in ALL modes (legacy difficulty ranges no longer clamp the pool since the Note Set dropdown was removed). - Tests 174/174.
This commit is contained in:
@@ -87,8 +87,8 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rhythm.active && (
|
||||
<div className="rhythm-details" style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{/* Always visible: pendulum + BPM; the toggle gates sound + animation */}
|
||||
<div className="rhythm-details" style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', opacity: rhythm.active ? 1 : 0.5 }}>
|
||||
{/* Linear pendulum — same element, left transitions each beat */}
|
||||
<div
|
||||
style={{
|
||||
@@ -111,13 +111,28 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
||||
background: 'var(--color-text-main)',
|
||||
transform: 'translateY(-50%)',
|
||||
transition: `left ${beatMs}ms linear`,
|
||||
display: rhythm.active ? 'block' : 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{rhythm.mode === 'bpm' || compact ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{!compact && (
|
||||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||||
<button
|
||||
className={`control-button small ${rhythm.mode === 'bpm' ? 'active' : ''}`}
|
||||
onClick={() => onUpdate({ mode: 'bpm' })}
|
||||
style={{ fontSize: '10px' }}
|
||||
>BPM</button>
|
||||
<button
|
||||
className={`control-button small ${rhythm.mode === 'seconds' ? 'active' : ''}`}
|
||||
onClick={() => onUpdate({ mode: 'seconds' })}
|
||||
style={{ fontSize: '10px' }}
|
||||
>Timer</button>
|
||||
</div>
|
||||
)}
|
||||
{rhythm.mode === 'bpm' ? (
|
||||
<>
|
||||
<button
|
||||
className="control-button small"
|
||||
onClick={() => onUpdate({ bpm: Math.max(30, rhythm.bpm - 1) })}
|
||||
@@ -150,13 +165,9 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
||||
onChange={(e) => onUpdate({ bpm: Number(e.target.value) })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.7, textAlign: 'center' }}>
|
||||
{getTempoMarking(rhythm.bpm)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<>
|
||||
<span style={{ fontSize: '12px', minWidth: '32px' }}>{rhythm.seconds}s</span>
|
||||
<input
|
||||
type="range"
|
||||
@@ -167,9 +178,37 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
||||
onChange={(e) => onUpdate({ seconds: Number(e.target.value) })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{rhythm.mode === 'bpm' && (
|
||||
<div style={{ fontSize: '11px', opacity: 0.7, textAlign: 'center' }}>
|
||||
{getTempoMarking(rhythm.bpm)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{showAutoAdvance ? (
|
||||
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rhythm.autoAdvance}
|
||||
onChange={(e) => onUpdate({ autoAdvance: e.target.checked })}
|
||||
/>
|
||||
Auto
|
||||
</label>
|
||||
) : <span />}
|
||||
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rhythm.sound}
|
||||
onChange={(e) => onUpdate({ sound: e.target.checked })}
|
||||
/>
|
||||
Sound
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
{showAutoAdvance ? (
|
||||
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
@@ -191,7 +230,5 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../ho
|
||||
import { getPhraseSettings, getPracticeFilter, type PhraseSettings, type PracticeFilter } from '../types/SettingsTypes';
|
||||
import { rawFrameCount } from '../hooks/rawFrameBus';
|
||||
import { audioEngine } from '../audio/AudioEngine';
|
||||
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange';
|
||||
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from '../music/playableRange';
|
||||
import { generateMelody } from '../music/melodyGenerator';
|
||||
import { generateScaleDrill } from '../music/scaleDrills';
|
||||
import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator';
|
||||
@@ -109,40 +109,23 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
|
||||
// ---- Playable pool ----------------------------------------------------
|
||||
const pool = useMemo(() => {
|
||||
// Unified filter: strings ∩ fret window take precedence for fretted
|
||||
// instruments (same semantics as the single-note modes).
|
||||
if (fretted && currentTuning && (pf.strings.length > 0 || pf.fretWindowEnabled)) {
|
||||
// Unified filter: strings ∩ fret window, computed identically to
|
||||
// the single-note modes (full 0–12 fretboard when no filter is
|
||||
// active — the legacy difficulty ranges no longer apply).
|
||||
if (fretted && currentTuning) {
|
||||
const all = currentTuning.strings;
|
||||
const selected = pf.strings.length > 0
|
||||
? all.filter((_, i) => pf.strings.includes(i))
|
||||
: all;
|
||||
if (selected.length > 0) {
|
||||
if (pf.fretWindowEnabled) {
|
||||
const lo = Math.max(0, Math.min(24, pf.fretMin));
|
||||
const hi = Math.max(lo, Math.min(24, pf.fretMax));
|
||||
if (selected.length === 0) return [];
|
||||
const lo = pf.fretWindowEnabled ? Math.max(0, Math.min(24, pf.fretMin)) : 0;
|
||||
const hi = pf.fretWindowEnabled ? Math.max(lo, Math.min(24, pf.fretMax)) : 12;
|
||||
return selected.flatMap(open => {
|
||||
const notes: number[] = [];
|
||||
for (let fret = lo; fret <= hi; fret++) notes.push(open + fret);
|
||||
return notes;
|
||||
}).sort((a, b) => a - b);
|
||||
}
|
||||
// Strings only: use their full range
|
||||
const maxFret = Math.max(...all.map(open => {
|
||||
let f = 0;
|
||||
while (open + f <= 127) f++;
|
||||
return Math.min(f - 1, 24);
|
||||
}));
|
||||
return selected.flatMap(open => {
|
||||
const notes: number[] = [];
|
||||
for (let fret = 0; fret <= maxFret; fret++) notes.push(open + fret);
|
||||
return notes;
|
||||
}).sort((a, b) => a - b);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (phrase.fretWindowEnabled && fretted && currentTuning) {
|
||||
return fretWindowNotes(currentTuning, phrase.fretMin, phrase.fretMax);
|
||||
}
|
||||
return computePlayableNotes({
|
||||
instrumentId: settings.instrument,
|
||||
difficulty: settings.difficulty,
|
||||
@@ -640,6 +623,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
<option value="down">Down</option>
|
||||
<option value="updown">Up & down</option>
|
||||
<option value="1235">1-2-3-5</option>
|
||||
<optgroup label="Giuliani studies">
|
||||
<option value="giuliani-pim">p-i-m</option>
|
||||
<option value="giuliani-pima">p-i-m-a</option>
|
||||
<option value="giuliani-pami">p-a-m-i</option>
|
||||
<option value="giuliani-mami">m-a-m-i</option>
|
||||
<option value="giuliani-ami">a-m-i</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<label>Coverage
|
||||
|
||||
@@ -14,7 +14,15 @@ import { STEP_LETTERS, LETTER_PC, type StepLetter } from '../score/model';
|
||||
import { makeRng } from './melodyGenerator';
|
||||
|
||||
export type ArpeggioDegree = 'I' | 'ii' | 'iii' | 'IV' | 'V' | 'vi' | 'vii0';
|
||||
export type ArpeggioPattern = 'up' | 'down' | 'updown' | '1235';
|
||||
export type ArpeggioPattern =
|
||||
| 'up' | 'down' | 'updown' | '1235'
|
||||
// Giuliani-style right-hand figures over one chord (indices into the
|
||||
// chord cycle per note; 1=root 2=third 3=fifth 4=octave):
|
||||
| 'giuliani-pim' // root-third-fifth (p-i-m)
|
||||
| 'giuliani-pima' // root-third-fifth-octave (p-i-m-a)
|
||||
| 'giuliani-pami' // root-octave-fifth-third (p-a-m-i)
|
||||
| 'giuliani-mami' // third-fifth-octave-third (inner-voice feel)
|
||||
| 'giuliani-ami'; // octave-fifth-third (descending from the top)
|
||||
export type ArpeggioCoverage = 'one-octave' | 'two-octave';
|
||||
/** How the chord per bar is chosen in sequence mode. */
|
||||
export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle';
|
||||
@@ -45,6 +53,28 @@ export const ARPEGGIO_DEGREE_LABELS: Record<ArpeggioDegree, string> = {
|
||||
I: 'I', ii: 'ii', iii: 'iii', IV: 'IV', V: 'V', vi: 'vi', 'vii0': 'vii°',
|
||||
};
|
||||
|
||||
export const PATTERN_LABELS: Record<ArpeggioPattern, string> = {
|
||||
up: 'Up', down: 'Down', updown: 'Up & down', '1235': '1-2-3-5',
|
||||
'giuliani-pim': 'Giuliani p-i-m',
|
||||
'giuliani-pima': 'Giuliani p-i-m-a',
|
||||
'giuliani-pami': 'Giuliani p-a-m-i',
|
||||
'giuliani-mami': 'Giuliani m-a-m-i',
|
||||
'giuliani-ami': 'Giuliani a-m-i',
|
||||
};
|
||||
|
||||
/**
|
||||
* Giuliani figures: chord-cycle index per note (0=root 1=third 2=fifth
|
||||
* 3=octave). Each figure repeats with an octave register shift per
|
||||
* repetition, mirroring the p-i-m-a feel of the 120 right-hand studies.
|
||||
*/
|
||||
const GIULIANI_FIGURES: Record<string, number[]> = {
|
||||
'giuliani-pim': [0, 1, 2],
|
||||
'giuliani-pima': [0, 1, 2, 3],
|
||||
'giuliani-pami': [0, 3, 2, 1],
|
||||
'giuliani-mami': [1, 2, 3, 1],
|
||||
'giuliani-ami': [3, 2, 1],
|
||||
};
|
||||
|
||||
/** pc of a spelled degree (step letter + alter). */
|
||||
const degreePc = (d: { step: StepLetter; alter: number }): number =>
|
||||
(LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12;
|
||||
@@ -146,6 +176,26 @@ function buildPath(key: KeyContext, degreeIndex: number, pool: number[], coverag
|
||||
}
|
||||
return path;
|
||||
}
|
||||
default: {
|
||||
// Giuliani figure: cycle [root, third, fifth, octave] with the
|
||||
// figure selecting cycle positions per note; the octave wraps so
|
||||
// e.g. p-i-m over C-E-G becomes C E G | C' E' G' ... (ascending
|
||||
// octave register shift every figure repetition).
|
||||
const figure = GIULIANI_FIGURES[pattern];
|
||||
// 4-note cycle per octave: root, third, fifth, octave
|
||||
const cycleOct = (oct: number): number[] => [
|
||||
base[0] + 12 * oct, base[1] + 12 * oct, base[2] + 12 * oct, base[0] + 12 * (oct + 1),
|
||||
];
|
||||
const path: number[] = [];
|
||||
for (let oct = 0; oct < octavesWanted; oct++) {
|
||||
const c = cycleOct(oct);
|
||||
for (const idx of figure) {
|
||||
const note = c[idx];
|
||||
if (note !== undefined && (oct === 0 || pool.includes(note))) path.push(note);
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +322,7 @@ export function generateArpeggio(
|
||||
}
|
||||
|
||||
const chordName = `${key.tonic} ${ARPEGGIO_DEGREE_LABELS[config.degree]}`;
|
||||
const patternLabel = { up: 'up', down: 'down', updown: 'up & down', '1235': '1-2-3-5' }[config.pattern];
|
||||
const patternLabel = PATTERN_LABELS[config.pattern];
|
||||
// Single-bar paths can overflow into extra bars (updown/two-octave);
|
||||
// sequence mode always has exactly `bars` measures.
|
||||
const totalMeasures = bars === 1 ? Math.max(1, Math.ceil((cursor) / full)) : bars;
|
||||
|
||||
@@ -40,7 +40,10 @@ export function computeTargetPool(input: TargetPoolInput): number[] {
|
||||
const tuning = TUNINGS[input.tuningId];
|
||||
const fretted = isFrettedInstrument(input.instrumentId) && !!tuning;
|
||||
|
||||
// 1+2: base pool with string selection and optional fret window
|
||||
// 1+2: base pool with string selection and optional fret window.
|
||||
// For fretted instruments with NO filters the pool is the full fretboard
|
||||
// (frets 0–12) — the legacy difficulty ranges no longer apply since the
|
||||
// Note Set dropdown was removed.
|
||||
let base: number[];
|
||||
if (fretted) {
|
||||
const all = tuning!.strings;
|
||||
@@ -49,7 +52,7 @@ export function computeTargetPool(input: TargetPoolInput): number[] {
|
||||
: all;
|
||||
if (selected.length === 0) return [];
|
||||
const lo = input.fretWindowEnabled ? Math.max(0, Math.min(24, input.fretMin)) : 0;
|
||||
const hi = input.fretWindowEnabled ? Math.max(lo, Math.min(24, input.fretMax)) : 24;
|
||||
const hi = input.fretWindowEnabled ? Math.max(lo, Math.min(24, input.fretMax)) : 12;
|
||||
base = selected.flatMap(open => {
|
||||
const notes: number[] = [];
|
||||
for (let fret = lo; fret <= hi; fret++) notes.push(open + fret);
|
||||
|
||||
Reference in New Issue
Block a user