mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
- 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).
256 lines
13 KiB
TypeScript
256 lines
13 KiB
TypeScript
/**
|
|
* Unified target-note controls, shared by all game modes (v2 controls).
|
|
*
|
|
* - Key filter: restrict target notes to a tonic+mode (independent of the
|
|
* displayed key signature on the staff — optional sync checkbox).
|
|
* - Strings: pick which strings contribute target notes (fretted instruments).
|
|
* - Fret window: restrict target notes to a fret range (fretted instruments).
|
|
*
|
|
* In Phrase Mode the same controls bind to the shared practice filter
|
|
* (mirrored into phrase settings), so state stays consistent across modes.
|
|
*/
|
|
import { useMemo } from 'react';
|
|
import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
|
|
import { useSettings } from '../context/useSettings';
|
|
import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes';
|
|
import { MODE_LABELS, TONICS, isMode, keyFor, type ModeId } from '../music/scales';
|
|
import { isFrettedInstrument } from '../music/playableRange';
|
|
import { TUNINGS } from '../music/Tunings';
|
|
|
|
/** Conventional string names, low→high, for guitar/bass tunings. */
|
|
const GUITAR_STRING_NAMES = ['E6', 'A5', 'D4', 'G3', 'B2', 'E1'];
|
|
const BASS_STRING_NAMES = ['E4', 'A3', 'D2', 'G1'];
|
|
|
|
function stringNames(tuningId: string, count: number): string[] {
|
|
const names = tuningId.startsWith('bass') ? BASS_STRING_NAMES : GUITAR_STRING_NAMES;
|
|
// Align from the low end (index 0 = lowest)
|
|
if (count === names.length) return names;
|
|
return Array.from({ length: count }, (_, i) => {
|
|
const midi = TUNINGS[tuningId]?.strings[i] ?? 0;
|
|
const pc = midi % 12;
|
|
const LETTERS = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'];
|
|
return LETTERS[pc] ?? `S${i + 1}`;
|
|
});
|
|
}
|
|
|
|
/** Display key signature names that correspond to a tonic+mode pair. */
|
|
const MAJOR_TONIC_TO_SIGNATURE: Record<string, string> = {
|
|
'C': 'C', 'G': 'G', 'D': 'D', 'A': 'A', 'E': 'E', 'B': 'C#', 'F#': 'C#',
|
|
'Db': 'Db', 'Ab': 'Ab', 'Eb': 'Eb', 'Bb': 'Bb', 'F': 'F',
|
|
};
|
|
const MINOR_TONIC_TO_SIGNATURE: Record<string, string> = {
|
|
'C': 'Eb', 'G': 'Bb', 'D': 'F', 'A': 'C', 'E': 'G', 'B': 'D', 'F#': 'A',
|
|
'Db': 'Eb', 'Ab': 'Bb', 'Eb': 'Gb', 'Bb': 'Db', 'F': 'Dm',
|
|
};
|
|
|
|
export const TargetNoteControls: React.FC = () => {
|
|
const { settings, updateSettings } = useSettings();
|
|
const pf = getPracticeFilter(settings);
|
|
|
|
const setFilter = (updates: Partial<PracticeFilter>) => {
|
|
updateSettings(s => {
|
|
const next: typeof s = { ...s, practice: { ...getPracticeFilter(s), ...updates } };
|
|
// Optional coupling: display key signature follows the target key.
|
|
if (updates.keyTonic !== undefined || updates.keyMode !== undefined) {
|
|
const tonic = updates.keyTonic ?? pf.keyTonic;
|
|
const mode = updates.keyMode ?? pf.keyMode;
|
|
if (s.keyFollowsTarget) {
|
|
const table = mode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
|
|
const sig = table[tonic];
|
|
if (sig) next.keySignature = sig;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const fretted = useMemo(
|
|
() => isFrettedInstrument(settings.instrument) && !!TUNINGS[settings.tuningId],
|
|
[settings.instrument, settings.tuningId],
|
|
);
|
|
|
|
const stringCount = TUNINGS[settings.tuningId]?.strings.length ?? 0;
|
|
const names = stringNames(settings.tuningId, stringCount);
|
|
|
|
const toggleString = (index: number) => {
|
|
const cur = pf.strings.length > 0 ? [...pf.strings] : Array.from({ length: stringCount }, (_, i) => i);
|
|
const next = cur.includes(index) ? cur.filter(i => i !== index) : [...cur, index].sort((a, b) => a - b);
|
|
// Deselecting all = back to "all strings" (empty array)
|
|
setFilter({ strings: next.length === stringCount ? [] : next });
|
|
};
|
|
|
|
const allSelected = pf.strings.length === 0;
|
|
|
|
const anyActive = pf.keyEnabled || pf.fretWindowEnabled || pf.strings.length > 0;
|
|
return (
|
|
<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 */}
|
|
<div className="control-group">
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<label className="control-label">
|
|
<Key size={18} />
|
|
<span>Key</span>
|
|
</label>
|
|
<button
|
|
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
|
|
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
|
|
title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'}
|
|
>
|
|
<div className="switch-thumb" />
|
|
</button>
|
|
</div>
|
|
{pf.keyEnabled && (
|
|
<>
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
|
<select
|
|
className="control-select"
|
|
value={pf.keyTonic}
|
|
onChange={e => setFilter({ keyTonic: e.target.value })}
|
|
aria-label="Target key tonic"
|
|
>
|
|
{Object.keys(TONICS).filter(t => !['C#', 'Gb', 'D#', 'G#', 'A#'].includes(t)).map(t => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
className="control-select"
|
|
value={pf.keyMode}
|
|
onChange={e => setFilter({ keyMode: isMode(e.target.value) ? e.target.value : 'major' })}
|
|
aria-label="Target key mode"
|
|
>
|
|
{(Object.keys(MODE_LABELS) as ModeId[]).map(m => (
|
|
<option key={m} value={m}>{MODE_LABELS[m]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={settings.keyFollowsTarget ?? false}
|
|
onChange={e => updateSettings(s => {
|
|
const next = { ...s, keyFollowsTarget: e.target.checked };
|
|
// When enabling sync, snap the display signature now.
|
|
if (e.target.checked) {
|
|
const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
|
|
const sig = table[pf.keyTonic];
|
|
if (sig) next.keySignature = sig;
|
|
}
|
|
return next;
|
|
})}
|
|
/>
|
|
Sync display key signature with target key
|
|
</label>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Strings (fretted instruments) */}
|
|
{fretted && (
|
|
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<label className="control-label">
|
|
<Guitar size={18} />
|
|
<span>Strings</span>
|
|
</label>
|
|
<button
|
|
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>
|
|
{!allSelected && <div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap' }}>
|
|
{Array.from({ length: stringCount }, (_, i) => {
|
|
const active = allSelected || pf.strings.includes(i);
|
|
return (
|
|
<button
|
|
key={i}
|
|
className={`control-button small ${active ? 'active' : ''}`}
|
|
onClick={() => toggleString(i)}
|
|
title={`String ${i + 1} (${names[i]})`}
|
|
style={{ flex: '1 1 0', minWidth: '40px', padding: '6px 4px', fontSize: '11px' }}
|
|
>
|
|
{names[i]}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>}
|
|
{pf.strings.length > 0 && (
|
|
<button
|
|
className="link-button"
|
|
style={{ fontSize: '11px', marginTop: '6px', padding: 0 }}
|
|
onClick={() => setFilter({ strings: [] })}
|
|
>
|
|
Reset to all strings
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Fret window (fretted instruments) */}
|
|
{fretted && (
|
|
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<label className="control-label">
|
|
<Music size={18} />
|
|
<span>Fret window</span>
|
|
</label>
|
|
<button
|
|
className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`}
|
|
onClick={() => setFilter({ fretWindowEnabled: !pf.fretWindowEnabled })}
|
|
title={pf.fretWindowEnabled ? 'Fret window: on' : 'Fret window: off'}
|
|
>
|
|
<div className="switch-thumb" />
|
|
</button>
|
|
</div>
|
|
{pf.fretWindowEnabled && (
|
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', alignItems: 'center' }}>
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
<span style={{ fontSize: '12px', opacity: 0.7 }}>Min</span>
|
|
<input
|
|
type="number" min={0} max={24}
|
|
className="control-input"
|
|
style={{ width: '100%', padding: '4px' }}
|
|
value={pf.fretMin}
|
|
onChange={e => setFilter({ fretMin: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })}
|
|
/>
|
|
</div>
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
<span style={{ fontSize: '12px', opacity: 0.7 }}>Max</span>
|
|
<input
|
|
type="number" min={0} max={24}
|
|
className="control-input"
|
|
style={{ width: '100%', padding: '4px' }}
|
|
value={pf.fretMax}
|
|
onChange={e => setFilter({ fretMax: Math.max(pf.fretMin, Math.min(24, parseInt(e.target.value) || 0)) })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Re-export keyFor for potential external use (avoids unused-import lint)
|
|
void keyFor;
|