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:
OpenClaw
2026-09-14 19:20:15 +00:00
parent 263b6f6b69
commit 9dc95e3787
4 changed files with 181 additions and 101 deletions

View File

@@ -87,8 +87,8 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
</button> </button>
</div> </div>
{rhythm.active && ( {/* 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)' }}> <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 */} {/* Linear pendulum — same element, left transitions each beat */}
<div <div
style={{ style={{
@@ -111,13 +111,28 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
background: 'var(--color-text-main)', background: 'var(--color-text-main)',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
transition: `left ${beatMs}ms linear`, transition: `left ${beatMs}ms linear`,
display: rhythm.active ? 'block' : 'none',
}} }}
/> />
</div> </div>
{rhythm.mode === 'bpm' || compact ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', 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 <button
className="control-button small" className="control-button small"
onClick={() => onUpdate({ bpm: Math.max(30, rhythm.bpm - 1) })} 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) })} onChange={(e) => onUpdate({ bpm: Number(e.target.value) })}
style={{ flex: 1 }} 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> <span style={{ fontSize: '12px', minWidth: '32px' }}>{rhythm.seconds}s</span>
<input <input
type="range" type="range"
@@ -167,9 +178,37 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
onChange={(e) => onUpdate({ seconds: Number(e.target.value) })} onChange={(e) => onUpdate({ seconds: Number(e.target.value) })}
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
</>
)}
</div>
{rhythm.mode === 'bpm' && (
<div style={{ fontSize: '11px', opacity: 0.7, textAlign: 'center' }}>
{getTempoMarking(rhythm.bpm)}
</div> </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' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{showAutoAdvance ? ( {showAutoAdvance ? (
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}> <label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
@@ -191,7 +230,5 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
</label> </label>
</div> </div>
</div> </div>
)}
</div>
); );
}; };

View File

@@ -16,7 +16,7 @@ import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../ho
import { getPhraseSettings, getPracticeFilter, type PhraseSettings, type PracticeFilter } from '../types/SettingsTypes'; import { getPhraseSettings, getPracticeFilter, type PhraseSettings, type PracticeFilter } from '../types/SettingsTypes';
import { rawFrameCount } from '../hooks/rawFrameBus'; import { rawFrameCount } from '../hooks/rawFrameBus';
import { audioEngine } from '../audio/AudioEngine'; 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 { generateMelody } from '../music/melodyGenerator';
import { generateScaleDrill } from '../music/scaleDrills'; import { generateScaleDrill } from '../music/scaleDrills';
import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator'; import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator';
@@ -109,40 +109,23 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
// ---- Playable pool ---------------------------------------------------- // ---- Playable pool ----------------------------------------------------
const pool = useMemo(() => { const pool = useMemo(() => {
// Unified filter: strings ∩ fret window take precedence for fretted // Unified filter: strings ∩ fret window, computed identically to
// instruments (same semantics as the single-note modes). // the single-note modes (full 0–12 fretboard when no filter is
if (fretted && currentTuning && (pf.strings.length > 0 || pf.fretWindowEnabled)) { // active — the legacy difficulty ranges no longer apply).
if (fretted && currentTuning) {
const all = currentTuning.strings; const all = currentTuning.strings;
const selected = pf.strings.length > 0 const selected = pf.strings.length > 0
? all.filter((_, i) => pf.strings.includes(i)) ? all.filter((_, i) => pf.strings.includes(i))
: all; : all;
if (selected.length > 0) { if (selected.length === 0) return [];
if (pf.fretWindowEnabled) { const lo = pf.fretWindowEnabled ? Math.max(0, Math.min(24, pf.fretMin)) : 0;
const lo = Math.max(0, Math.min(24, pf.fretMin)); const hi = pf.fretWindowEnabled ? Math.max(lo, Math.min(24, pf.fretMax)) : 12;
const hi = Math.max(lo, Math.min(24, pf.fretMax));
return selected.flatMap(open => { return selected.flatMap(open => {
const notes: number[] = []; const notes: number[] = [];
for (let fret = lo; fret <= hi; fret++) notes.push(open + fret); for (let fret = lo; fret <= hi; fret++) notes.push(open + fret);
return notes; return notes;
}).sort((a, b) => a - b); }).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({ return computePlayableNotes({
instrumentId: settings.instrument, instrumentId: settings.instrument,
difficulty: settings.difficulty, difficulty: settings.difficulty,
@@ -640,6 +623,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
<option value="down">Down</option> <option value="down">Down</option>
<option value="updown">Up &amp; down</option> <option value="updown">Up &amp; down</option>
<option value="1235">1-2-3-5</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> </select>
</label> </label>
<label>Coverage <label>Coverage

View File

@@ -14,7 +14,15 @@ import { STEP_LETTERS, LETTER_PC, type StepLetter } from '../score/model';
import { makeRng } from './melodyGenerator'; import { makeRng } from './melodyGenerator';
export type ArpeggioDegree = 'I' | 'ii' | 'iii' | 'IV' | 'V' | 'vi' | 'vii0'; 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'; export type ArpeggioCoverage = 'one-octave' | 'two-octave';
/** How the chord per bar is chosen in sequence mode. */ /** How the chord per bar is chosen in sequence mode. */
export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle'; 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°', 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). */ /** pc of a spelled degree (step letter + alter). */
const degreePc = (d: { step: StepLetter; alter: number }): number => const degreePc = (d: { step: StepLetter; alter: number }): number =>
(LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12; (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; 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 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); // Single-bar paths can overflow into extra bars (updown/two-octave);
// sequence mode always has exactly `bars` measures. // sequence mode always has exactly `bars` measures.
const totalMeasures = bars === 1 ? Math.max(1, Math.ceil((cursor) / full)) : bars; const totalMeasures = bars === 1 ? Math.max(1, Math.ceil((cursor) / full)) : bars;

View File

@@ -40,7 +40,10 @@ export function computeTargetPool(input: TargetPoolInput): number[] {
const tuning = TUNINGS[input.tuningId]; const tuning = TUNINGS[input.tuningId];
const fretted = isFrettedInstrument(input.instrumentId) && !!tuning; 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[]; let base: number[];
if (fretted) { if (fretted) {
const all = tuning!.strings; const all = tuning!.strings;
@@ -49,7 +52,7 @@ export function computeTargetPool(input: TargetPoolInput): number[] {
: all; : all;
if (selected.length === 0) return []; if (selected.length === 0) return [];
const lo = input.fretWindowEnabled ? Math.max(0, Math.min(24, input.fretMin)) : 0; 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 => { base = selected.flatMap(open => {
const notes: number[] = []; const notes: number[] = [];
for (let fret = lo; fret <= hi; fret++) notes.push(open + fret); for (let fret = lo; fret <= hi; fret++) notes.push(open + fret);