UI consolidation round: pace checkbox, unified Limit notes section, fixes

- Pace dropdown REMOVED from Phrase setup — replaced by a 'Sync to
  exercise' checkbox in the Metronome widget (analog to 'Auto' in
  single-note modes). pace is derived from the checkbox (checked = tempo,
  unchecked = step).
- Preview clicks now registered in PREVIEW_GROUP: stopping/abandoning a
  preview cancels its scheduled clicks — no more parallel ghost
  metronomes after preview->stop->next exercise.
- Chord pool replaces the chord dropdown entirely (single chord AND
  sequences): togglable degree list, empty = all 7.
- Giuliani figures corrected to NOTE sequences (p always takes the bass/
  root): p-i-m, p-i-m-a, p-a-m-i, a-i-m, p-i-m-a-m-i, p-m-a-m-i-m,
  p-e-a-m-a-m (replaced the fingering-driven patterns that made no
  musical sense).
- 'Limit notes' is now one bordered section like Tuner/Metronome, with a
  header, Clear-all link, and Strings behind a toggle (row hidden until
  restricted).
This commit is contained in:
OpenClaw
2026-09-14 21:08:15 +00:00
parent 6253194632
commit 9acf32a32f
6 changed files with 120 additions and 116 deletions

View File

@@ -140,8 +140,10 @@ class AudioEngine {
* Short NON-TONAL metronome click (~10 ms noise burst). Not registered in * Short NON-TONAL metronome click (~10 ms noise burst). Not registered in
* the pitched-audio gate; instead the mic is blanked for 30 ms so the * the pitched-audio gate; instead the mic is blanked for 30 ms so the
* click itself can't be scored as a pitch (contract E2). * click itself can't be scored as a pitch (contract E2).
* Optional groupId: the click becomes cancellable via cancelGroup (used
* by preview clicks so stopping a preview also stops its clicks).
*/ */
playClickAt(time: number, volume = 0.5, accent = false): void { playClickAt(time: number, volume = 0.5, accent = false, groupId?: string): void {
const ctx = this.ensureContext(); const ctx = this.ensureContext();
if (!this.noiseBuffer) { if (!this.noiseBuffer) {
const len = Math.floor(ctx.sampleRate * 0.01); const len = Math.floor(ctx.sampleRate * 0.01);
@@ -160,6 +162,11 @@ class AudioEngine {
src.start(time); src.start(time);
src.stop(time + 0.02); src.stop(time + 0.02);
this.clickBlankUntil = Math.max(this.clickBlankUntil, time + 0.03); this.clickBlankUntil = Math.max(this.clickBlankUntil, time + 0.03);
if (groupId) {
// Register as a cancellable node (cast: clicks are buffer sources,
// cancelGroup only calls stop() on them, which BufferSource has).
this.active.push({ osc: src as unknown as OscillatorNode, gain: gainNode, endsAt: time + 0.02, groupId });
}
} }
/** True for ~30 ms after a scheduled click (mic frames should be skipped). */ /** True for ~30 ms after a scheduled click (mic frames should be skipped). */

View File

@@ -27,7 +27,10 @@ interface MetronomeWidgetProps {
showAutoAdvance?: boolean; showAutoAdvance?: boolean;
/** Compact layout (phrase transport row). */ /** Compact layout (phrase transport row). */
compact?: boolean; compact?: boolean;
/** 'option': show the sync/free toggle (Phrase Mode). null: plain on/off. */ /** 'option': show the sync checkbox (Phrase Mode) that replaces the pace
* dropdown: checked = clicks/beats only with the exercise (preview &
* run, count-in included); unchecked = free-running metronome.
* null: plain on/off (single-note modes). */
syncMode?: 'option' | null; syncMode?: 'option' | null;
/** External gate (sync mode): true while the exercise runs. */ /** External gate (sync mode): true while the exercise runs. */
gate?: boolean | null; gate?: boolean | null;
@@ -85,18 +88,14 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
<span>Metronome</span> <span>Metronome</span>
</label> </label>
{syncMode === 'option' ? ( {syncMode === 'option' ? (
<button <label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
className={`switch-button ${rhythm.syncToExercise ? 'active' : ''}`} <input
onClick={() => onUpdate({ type="checkbox"
syncToExercise: !rhythm.syncToExercise, checked={!!rhythm.syncToExercise}
active: true, // armed in either mode; the mode decides behavior onChange={(e) => onUpdate({ syncToExercise: e.target.checked })}
})} />
title={rhythm.syncToExercise Sync to exercise
? 'Sync: starts with the exercise (count-in). Click while idle: off.' </label>
: 'Free: runs independently — start/stop it yourself.'}
>
<div className="switch-thumb" />
</button>
) : ( ) : (
<button <button
className={`switch-button ${rhythm.active ? 'active' : ''}`} className={`switch-button ${rhythm.active ? 'active' : ''}`}

View File

@@ -82,16 +82,18 @@ 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;
// Pace is now DERIVED from the metronome widget's sync checkbox:
// checked = tempo (sync), unchecked = step (at your pace).
const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step';
phrase.pace = pace;
// Click decision — ONE source: the metronome widget's sound checkbox. // Click decision — ONE source: the metronome widget's sound checkbox.
// - Tempo + syncToExercise: the run clicks when armed && sound // - Sync (tempo): run clicks when armed && sound (count-in incl.);
// (silent while idle; starts with the exercise incl. count-in). // widget silent while idle.
// - Tempo + free metronome: the widget clicks freely; the run stays // - Free metronome: widget clicks freely; the run stays silent to
// silent (its beats are visual only) to avoid doubling. // avoid doubling (sync off in tempo = free widget during runs).
// - Step: widget clicks freely; phrase clickSound follows the switch. phrase.clickSound = pace === 'tempo'
const sync = settings.rhythm.syncToExercise; ? settings.rhythm.active && settings.rhythm.sound
phrase.clickSound = phrase.pace === 'tempo' : settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise;
? (sync ? settings.rhythm.active && settings.rhythm.sound : false)
: settings.rhythm.active && settings.rhythm.sound;
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 } };
@@ -616,13 +618,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
{phrase.material === 'arpeggio' && ( {phrase.material === 'arpeggio' && (
<> <>
<label>Chord
<select value={phrase.arpeggioDegree} onChange={e => setPhrase({ arpeggioDegree: e.target.value, arpeggioBars: 1 })}>
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => (
<option key={d} value={d}>{ARPEGGIO_DEGREE_LABELS[d]}</option>
))}
</select>
</label>
<label>Pattern <label>Pattern
<select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}> <select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}>
<option value="up">Up</option> <option value="up">Up</option>
@@ -633,12 +628,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
<option value="giuliani-pim">p-i-m</option> <option value="giuliani-pim">p-i-m</option>
<option value="giuliani-pima">p-i-m-a</option> <option value="giuliani-pima">p-i-m-a</option>
<option value="giuliani-pami">p-a-m-i</option> <option value="giuliani-pami">p-a-m-i</option>
<option value="giuliani-mami">m-a-m-i</option> <option value="giuliani-aim">a-i-m</option>
<option value="giuliani-ami">a-m-i</option>
<option value="giuliani-pimamim">p-i-m-a-m-i</option> <option value="giuliani-pimamim">p-i-m-a-m-i</option>
<option value="giuliani-papima">p-a-p-i-m-a</option> <option value="giuliani-pmamim">p-m-a-m-i-m</option>
<option value="giuliani-amipim">a-m-i-p-i-m</option> <option value="giuliani-peamama">p-e-a-m-a-m</option>
<option value="giuliani-miamim">m-i-a-m-i-m</option>
</optgroup> </optgroup>
</select> </select>
</label> </label>
@@ -662,10 +655,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
)} )}
{phrase.arpeggioBars > 1 && (
<div style={{ gridColumn: '1 / -1' }}> <div style={{ gridColumn: '1 / -1' }}>
<span style={{ fontSize: '11px', opacity: 0.7, display: 'block', marginBottom: '4px' }}> <span style={{ fontSize: '11px', opacity: 0.7, display: 'block', marginBottom: '4px' }}>
Chord pool ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}): Chords ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}):
</span> </span>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => { {(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => {
@@ -676,7 +668,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
className={`control-button small ${active ? 'active' : ''}`} className={`control-button small ${active ? 'active' : ''}`}
style={{ minWidth: '42px', padding: '5px 8px', fontSize: '12px' }} style={{ minWidth: '42px', padding: '5px 8px', fontSize: '12px' }}
onClick={() => { onClick={() => {
// Toggle; empty selection = all back on.
const cur = phrase.arpeggioChordSelection.length === 0 const cur = phrase.arpeggioChordSelection.length === 0
? (Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]) ? (Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[])
: [...phrase.arpeggioChordSelection as ArpeggioDegree[]]; : [...phrase.arpeggioChordSelection as ArpeggioDegree[]];
@@ -690,7 +681,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
})} })}
</div> </div>
</div> </div>
)}
<label>Rhythm <label>Rhythm
<select value={phrase.arpeggioRhythm} onChange={e => setPhrase({ arpeggioRhythm: e.target.value as PhraseSettings['arpeggioRhythm'] })}> <select value={phrase.arpeggioRhythm} onChange={e => setPhrase({ arpeggioRhythm: e.target.value as PhraseSettings['arpeggioRhythm'] })}>
<option value="quarters">Quarter notes</option> <option value="quarters">Quarter notes</option>
@@ -763,28 +753,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</span> </span>
)} )}
<label>Pace {/* Pace lives in the metronome widget now:
<select 'Sync to exercise' checked = tempo pace,
value={phrase.pace} unchecked = at-your-pace (step). */}
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">Sync to metronome</option>
</select>
</label>
{phrase.pace === 'tempo' && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Tempo (BPM): set it on the metronome in the tools below — shared across modes.
</span>
)}
<label className="phrase-check"> <label className="phrase-check">
<input <input
type="checkbox" type="checkbox"
@@ -795,7 +766,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
{phrase.pace === 'tempo' && ( {phrase.pace === 'tempo' && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}> <span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Clicks: use the metronome's "Click sound" checkbox (with count-in, synced). BPM + click: on the metronome widget below.
</span> </span>
)} )}
<label className="phrase-check"> <label className="phrase-check">

View File

@@ -10,7 +10,7 @@
* (mirrored into phrase settings), so state stays consistent across modes. * (mirrored into phrase settings), so state stays consistent across modes.
*/ */
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Key, Guitar, Music } from 'lucide-react'; import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
import { useSettings } from '../context/useSettings'; import { useSettings } from '../context/useSettings';
import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes'; import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes';
import { MODE_LABELS, TONICS, isMode, keyFor, type ModeId } from '../music/scales'; import { MODE_LABELS, TONICS, isMode, keyFor, type ModeId } from '../music/scales';
@@ -81,14 +81,32 @@ export const TargetNoteControls: React.FC = () => {
const allSelected = pf.strings.length === 0; const allSelected = pf.strings.length === 0;
const anyActive = pf.keyEnabled || pf.fretWindowEnabled || pf.strings.length > 0;
return ( return (
<div className="practice-filter-grid"> <div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}>
{/* Section header: unified with Tuner/Metronome style */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label" style={{ marginBottom: 0 }}>
<SlidersHorizontal size={18} />
<span>Limit notes</span>
</label>
{anyActive && (
<button
className="link-button"
style={{ fontSize: '11px', padding: 0 }}
onClick={() => setFilter({ keyEnabled: false, fretWindowEnabled: false, strings: [] })}
>
Clear all
</button>
)}
</div>
{/* Key filter */} {/* Key filter */}
<div className="control-group" style={{ gridColumn: '1 / -1' }}> <div className="control-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label"> <label className="control-label">
<Key size={18} /> <Key size={18} />
<span>Target key (limits notes)</span> <span>Key</span>
</label> </label>
<button <button
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`} className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
@@ -149,13 +167,17 @@ export const TargetNoteControls: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label"> <label className="control-label">
<Guitar size={18} /> <Guitar size={18} />
<span>Strings (limits notes)</span> <span>Strings</span>
</label> </label>
{allSelected && ( <button
<span style={{ fontSize: '11px', opacity: 0.7 }}>all</span> className={`switch-button ${!allSelected ? 'active' : ''}`}
)} onClick={() => setFilter({ strings: allSelected ? [0] : [] })}
title={allSelected ? 'All strings — toggle on to restrict' : 'Restrict to selected strings'}
>
<div className="switch-thumb" />
</button>
</div> </div>
<div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap' }}> {!allSelected && <div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap' }}>
{Array.from({ length: stringCount }, (_, i) => { {Array.from({ length: stringCount }, (_, i) => {
const active = allSelected || pf.strings.includes(i); const active = allSelected || pf.strings.includes(i);
return ( return (
@@ -170,7 +192,7 @@ export const TargetNoteControls: React.FC = () => {
</button> </button>
); );
})} })}
</div> </div>}
{pf.strings.length > 0 && ( {pf.strings.length > 0 && (
<button <button
className="link-button" className="link-button"
@@ -189,7 +211,7 @@ export const TargetNoteControls: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label"> <label className="control-label">
<Music size={18} /> <Music size={18} />
<span>Fret window (limits notes)</span> <span>Fret window</span>
</label> </label>
<button <button
className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`} className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`}

View File

@@ -507,11 +507,13 @@ export function usePhraseTrainer(
} }
// Tempo pace: click along with the preview so the rhythm is // Tempo pace: click along with the preview so the rhythm is
// audible while listening (quarter beats over the full span). // audible while listening (quarter beats over the full span).
// Registered in PREVIEW_GROUP so stopping the preview also stops
// its clicks (no ghost metronomes after an aborted preview).
if (paceRef.current === 'tempo' && configRef.current.clickSound) { if (paceRef.current === 'tempo' && configRef.current.clickSound) {
const totalTicks = events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0); const totalTicks = events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0);
const beats = Math.floor(totalTicks / PPQ); const beats = Math.floor(totalTicks / PPQ);
for (let i = 0; i < beats; i++) { for (let i = 0; i < beats; i++) {
audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, i % 4 === 0); audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, i % 4 === 0, PREVIEW_GROUP);
} }
} }
setPhaseBoth('preview'); setPhaseBoth('preview');

View File

@@ -21,12 +21,10 @@ export type ArpeggioPattern =
| 'giuliani-pim' // root-third-fifth (p-i-m) | 'giuliani-pim' // root-third-fifth (p-i-m)
| 'giuliani-pima' // root-third-fifth-octave (p-i-m-a) | 'giuliani-pima' // root-third-fifth-octave (p-i-m-a)
| 'giuliani-pami' // root-octave-fifth-third (p-a-m-i) | 'giuliani-pami' // root-octave-fifth-third (p-a-m-i)
| 'giuliani-mami' // third-fifth-octave-third (inner-voice feel) | 'giuliani-aim' // root-octave-third (upper voices first)
| 'giuliani-ami' // octave-fifth-third (descending from the top)
| 'giuliani-pimamim' // p-i-m-a-m-i (six-note rolling figure) | 'giuliani-pimamim' // p-i-m-a-m-i (six-note rolling figure)
| 'giuliani-papima' // p-a-p-i-m-a (bass-skip pattern) | 'giuliani-pmamim' // p-m-a-m-i-m (asymmetric upper-voice figure)
| 'giuliani-amipim' // a-m-i-p-i-m (tremolo-adjacent) | 'giuliani-peamama'; // p-e-a-m-a-m (wide bass + upper pairs)
| 'giuliani-miamim'; // m-i-a-m-i-m (triple-string figure)
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';
@@ -65,29 +63,34 @@ export const PATTERN_LABELS: Record<ArpeggioPattern, string> = {
'giuliani-pim': 'Giuliani p-i-m', 'giuliani-pim': 'Giuliani p-i-m',
'giuliani-pima': 'Giuliani p-i-m-a', 'giuliani-pima': 'Giuliani p-i-m-a',
'giuliani-pami': 'Giuliani p-a-m-i', 'giuliani-pami': 'Giuliani p-a-m-i',
'giuliani-mami': 'Giuliani m-a-m-i', 'giuliani-aim': 'Giuliani a-i-m',
'giuliani-ami': 'Giuliani a-m-i',
'giuliani-pimamim': 'Giuliani p-i-m-a-m-i', 'giuliani-pimamim': 'Giuliani p-i-m-a-m-i',
'giuliani-papima': 'Giuliani p-a-p-i-m-a', 'giuliani-pmamim': 'Giuliani p-m-a-m-i-m',
'giuliani-amipim': 'Giuliani a-m-i-p-i-m', 'giuliani-peamama': 'Giuliani p-e-a-m-a-m',
'giuliani-miamim': 'Giuliani m-i-a-m-i-m',
}; };
/** /**
* Giuliani figures: chord-cycle index per note (0=root 1=third 2=fifth * Giuliani figures — NOTE sequences (not fingerings): the thumb (p) always
* 3=octave). Each figure repeats with an octave register shift per * takes the bass/root, the remaining notes ride the upper chord tones.
* repetition, mirroring the p-i-m-a feel of the 120 right-hand studies. * Indices into the octave cycle [0=root, 1=third, 2=fifth, 3=octave]; the
* figure repeats with an ascending octave shift per repetition (the bass
* stays in place — like the Maestoso studies).
*/ */
const GIULIANI_FIGURES: Record<string, number[]> = { const GIULIANI_FIGURES: Record<string, number[]> = {
// Studies 1–20 feel: bass + two upper voices, ascending
'giuliani-pim': [0, 1, 2], 'giuliani-pim': [0, 1, 2],
// Studies 21–40 feel: bass + three upper voices
'giuliani-pima': [0, 1, 2, 3], 'giuliani-pima': [0, 1, 2, 3],
// Descending upper voices after the bass
'giuliani-pami': [0, 3, 2, 1], 'giuliani-pami': [0, 3, 2, 1],
'giuliani-mami': [1, 2, 3, 1], // Upper voices first (a-i-m), bass anchored at start of each cycle
'giuliani-ami': [3, 2, 1], 'giuliani-aim': [0, 3, 1],
// Six-note rolling figure (studies 61–80 territory)
'giuliani-pimamim': [0, 1, 2, 3, 2, 1], 'giuliani-pimamim': [0, 1, 2, 3, 2, 1],
'giuliani-papima': [0, 3, 0, 1, 2, 3], // Asymmetric: bass + upper triad roll
'giuliani-amipim': [3, 2, 1, 0, 1, 2], 'giuliani-pmamim': [0, 2, 3, 2, 1, 2],
'giuliani-miamim': [2, 1, 3, 2, 1, 2], // Wide: bass, fifth, octave, then upper-voice pairs
'giuliani-peamama': [0, 2, 3, 1, 3, 1],
}; };
/** pc of a spelled degree (step letter + alter). */ /** pc of a spelled degree (step letter + alter). */