Unified target-note controls: key filter + fret window across all modes

- New shared PracticeFilter settings (keyTonic/keyMode/fretWindow), persisted
  via AppSettings.practice, defaults off (behavior unchanged).
- music/targetPool.ts: single source for the effective target pool —
  base playable range ∩ fret window ∩ key/mode pitch classes (9 modes incl.
  pentatonic, same scale machinery as Phrase Mode).
- useGameLogic now builds validNotes via computeTargetPool.
- TargetNoteControls component (key on/off + tonic/mode, fret window on/off +
  min/max) rendered in Controls for all game modes; key is independent of the
  displayed key signature.
- Phrase Mode binds its key/fret-window setup to the same shared filter
  (read/write mirror) and drops the duplicate controls in its setup panel.
- Tests: targetPool.test.ts (7 cases); suite 156/156 green, build ok.
This commit is contained in:
OpenClaw
2026-09-14 07:16:29 +00:00
parent da060e5bb9
commit 15b8705907
7 changed files with 339 additions and 104 deletions

View File

@@ -3,6 +3,7 @@ import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { getTempoMarking } from '../music/TempoMarkings'; import { getTempoMarking } from '../music/TempoMarkings';
import { TuningMeter } from './TuningMeter'; import { TuningMeter } from './TuningMeter';
import { TargetNoteControls } from './TargetNoteControls';
import { useSettings } from '../context/useSettings'; import { useSettings } from '../context/useSettings';
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes'; import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
@@ -193,6 +194,9 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
)} )}
</div> </div>
{/* Target-note filter: unified controls, all modes */}
<TargetNoteControls />
{/* Bottom Section: Tools Grid (2 Columns now) */} {/* Bottom Section: Tools Grid (2 Columns now) */}
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}> <div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
{/* Tool 1: Tuner */} {/* Tool 1: Tuner */}

View File

@@ -13,13 +13,13 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
import { useSettings } from '../context/useSettings'; import { useSettings } from '../context/useSettings';
import { useAudioPlayer } from '../hooks/useAudioPlayer'; import { useAudioPlayer } from '../hooks/useAudioPlayer';
import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer'; import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer';
import { getPhraseSettings, type PhraseSettings } 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, fretWindowNotes } from '../music/playableRange';
import { generateMelody } from '../music/melodyGenerator'; import { generateMelody } from '../music/melodyGenerator';
import { generateScaleDrill } from '../music/scaleDrills'; import { generateScaleDrill } from '../music/scaleDrills';
import { isMode, MODE_LABELS, TONICS, type ModeId } from '../music/scales'; import { isMode, type ModeId } from '../music/scales';
import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model'; import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model';
import { getNoteDetails } from '../music/NoteUtils'; import { getNoteDetails } from '../music/NoteUtils';
import { EXERCISES, loadExercise } from '../exercises/library'; import { EXERCISES, loadExercise } from '../exercises/library';
@@ -68,8 +68,27 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const setSettings = updateSettings; const setSettings = updateSettings;
const { playNote } = useAudioPlayer(); const { playNote } = useAudioPlayer();
const phrase = getPhraseSettings(settings); const phrase = getPhraseSettings(settings);
// Unified target-note controls: Phrase Mode reads/writes the shared
// practice filter so all modes expose identical key/fret-window state.
const pf = getPracticeFilter(settings);
phrase.keyTonic = pf.keyTonic;
phrase.keyMode = pf.keyMode;
phrase.fretWindowEnabled = pf.fretWindowEnabled;
phrase.fretMin = pf.fretMin;
phrase.fretMax = pf.fretMax;
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => { const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } })); updateSettings(s => {
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
// Mirror shared filter fields into the unified practice filter.
const shared: Partial<PracticeFilter> = {};
if ('keyTonic' in updates) shared.keyTonic = updates.keyTonic;
if ('keyMode' in updates) shared.keyMode = updates.keyMode;
if ('fretWindowEnabled' in updates) shared.fretWindowEnabled = updates.fretWindowEnabled;
if ('fretMin' in updates) shared.fretMin = updates.fretMin;
if ('fretMax' in updates) shared.fretMax = updates.fretMax;
if (Object.keys(shared).length > 0) next.practice = { ...getPracticeFilter(s), ...shared };
return next;
});
}, [updateSettings]); }, [updateSettings]);
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
@@ -489,22 +508,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
{(phrase.material === 'melody' || phrase.material === 'scale') && ( {(phrase.material === 'melody' || phrase.material === 'scale') && (
<> <span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
<label>Key Key/mode: use the "Target key" controls below the sheet — shared across modes.
<select value={phrase.keyTonic} onChange={e => setPhrase({ keyTonic: e.target.value })}> </span>
{Object.keys(TONICS).filter(t => !['C#', 'Gb', 'D#', 'G#', 'A#'].includes(t)).map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
<label>Mode
<select value={phrase.keyMode} onChange={e => setPhrase({ keyMode: e.target.value })}>
{(Object.keys(MODE_LABELS) as ModeId[]).map(m => (
<option key={m} value={m}>{MODE_LABELS[m]}</option>
))}
</select>
</label>
</>
)} )}
{phrase.material === 'melody' && ( {phrase.material === 'melody' && (
@@ -604,28 +610,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
)} )}
{fretted && ( {fretted && (
<> <span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
<label className="phrase-check"> Fret window: use the "Fret window" controls below the sheet — shared across modes.
<input </span>
type="checkbox"
checked={phrase.fretWindowEnabled}
onChange={e => setPhrase({ fretWindowEnabled: e.target.checked })}
/>
Fret window
</label>
{phrase.fretWindowEnabled && (
<>
<label>Min fret
<input type="number" min={0} max={24} value={phrase.fretMin}
onChange={e => setPhrase({ fretMin: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })} />
</label>
<label>Max fret
<input type="number" min={0} max={24} value={phrase.fretMax}
onChange={e => setPhrase({ fretMax: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })} />
</label>
</>
)}
</>
)} )}
<label>Pace <label>Pace

View File

@@ -0,0 +1,119 @@
/**
* 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).
* - 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 } from 'lucide-react';
import { useSettings } from '../context/useSettings';
import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes';
import { MODE_LABELS, TONICS, isMode, type ModeId } from '../music/scales';
import { isFrettedInstrument } from '../music/playableRange';
import { TUNINGS } from '../music/Tunings';
export const TargetNoteControls: React.FC = () => {
const { settings, updateSettings } = useSettings();
const pf = getPracticeFilter(settings);
const setFilter = (updates: Partial<PracticeFilter>) => {
updateSettings(s => ({ ...s, practice: { ...getPracticeFilter(s), ...updates } }));
};
const fretted = useMemo(
() => isFrettedInstrument(settings.instrument) && !!TUNINGS[settings.tuningId],
[settings.instrument, settings.tuningId],
);
return (
<div className="practice-filter-grid">
{/* Key filter */}
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label">
<Key size={18} />
<span>Target key (limits notes)</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>
)}
</div>
{/* Fret window */}
{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>Fret window (limits notes)</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>
);
};

View File

@@ -8,12 +8,8 @@ import {
getRandomNote, getRandomNote,
getNoteDetails getNoteDetails
} from '../music/NoteUtils'; } from '../music/NoteUtils';
import { import { computeTargetPool } from '../music/targetPool';
TUNINGS, import { getPracticeFilter } from '../types/SettingsTypes';
getOpenStringNotes,
getFirstPositionNotes,
} from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { import {
NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_THRESHOLD_MS,
NOTE_MATCH_GRACE_MS, NOTE_MATCH_GRACE_MS,
@@ -36,65 +32,25 @@ export const useGameLogic = (
const [tracker] = useState(() => new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS)); const [tracker] = useState(() => new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS));
// --- Valid Notes Calculation --- // --- Valid Notes Calculation ---
const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const validNotes = useMemo(() => { const validNotes = useMemo(() => {
const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty); const pf = getPracticeFilter(settings);
return computeTargetPool({
const getNotesFromConfig = (config: typeof rangeConfig) => { instrumentId: settings.instrument,
if (!config) return []; difficulty: settings.difficulty,
tuningId: settings.tuningId,
if (config.type === 'open_strings') { customMinFret: settings.customMinFret,
if (currentTuning) return getOpenStringNotes(currentTuning); customMaxFret: settings.customMaxFret,
return config.notes || []; keyEnabled: pf.keyEnabled,
} keyTonic: pf.keyTonic,
keyMode: pf.keyMode,
if (config.type === 'first_position') { fretWindowEnabled: pf.fretWindowEnabled,
if (currentTuning) return getFirstPositionNotes(currentTuning); fretMin: pf.fretMin,
return config.notes || []; fretMax: pf.fretMax,
} });
}, [settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret,
if (config.type === 'custom_fret') { settings.practice?.keyEnabled, settings.practice?.keyTonic, settings.practice?.keyMode,
if (currentTuning) { settings.practice?.fretWindowEnabled, settings.practice?.fretMin, settings.practice?.fretMax]);
const minFret = settings.customMinFret ?? config.defaultMinFret ?? 0;
const maxFret = settings.customMaxFret ?? config.defaultMaxFret ?? 12;
const notes = new Set<number>();
currentTuning.strings.forEach(stringMidi => {
for (let fret = minFret; fret <= maxFret; fret++) {
notes.add(stringMidi + fret);
}
});
return Array.from(notes).sort((a, b) => a - b);
}
return [];
}
if (config.type === 'specific_string') {
if (currentTuning && config.stringIndex !== undefined) {
const openNote = currentTuning.strings[config.stringIndex];
if (openNote === undefined) return [];
const notes = [];
for (let i = 0; i <= 12; i++) {
notes.push(openNote + i);
}
return notes;
}
return [];
}
if (config.notes) return config.notes;
if (config.min !== undefined && config.max !== undefined) {
return Array.from({ length: config.max - config.min + 1 }, (_, i) => config.min! + i);
}
return [];
};
if (rangeConfig) return getNotesFromConfig(rangeConfig);
const fallbackRange = currentInstrumentDef.ranges[0];
return getNotesFromConfig(fallbackRange);
}, [settings.difficulty, currentInstrumentDef, currentTuning, settings.customMinFret, settings.customMaxFret]);
// --- Note Generation --- // --- Note Generation ---
const generateNewNote = useCallback((keepFeedback = false) => { const generateNewNote = useCallback((keepFeedback = false) => {

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { computeTargetPool } from './targetPool';
const base = {
instrumentId: 'guitar',
difficulty: 'custom',
tuningId: 'standard',
customMinFret: 0,
customMaxFret: 12,
keyEnabled: false,
keyTonic: 'C',
keyMode: 'major',
fretWindowEnabled: false,
fretMin: 0,
fretMax: 4,
};
describe('computeTargetPool', () => {
it('returns the base playable range when no filters are active', () => {
const pool = computeTargetPool(base);
expect(pool).toContain(40); // low E
expect(pool).toContain(64); // high e (12th fret)
expect(pool.length).toBeGreaterThan(30);
});
it('applies the fret window when enabled', () => {
const pool = computeTargetPool({ ...base, fretWindowEnabled: true, fretMin: 0, fretMax: 4 });
// guitar standard: open strings 40,45,50,55,59,64; window adds up to +4
const lo = Math.min(...pool);
const hi = Math.max(...pool);
expect(lo).toBe(40);
expect(hi).toBe(68); // 64 + 4
expect(pool).toContain(59); // open g
expect(pool).not.toContain(69);
});
it('restricts to pitch classes of the key when key filter is on', () => {
const pool = computeTargetPool({ ...base, keyEnabled: true, keyTonic: 'C', keyMode: 'major' });
const pcs = new Set([0, 2, 4, 5, 7, 9, 11]); // C major
for (const midi of pool) {
expect(pcs.has(((midi % 12) + 12) % 12)).toBe(true);
}
// chromatic notes excluded
expect(pool).not.toContain(61); // C# within range
});
it('combines fret window and key filter', () => {
const pool = computeTargetPool({ ...base, fretWindowEnabled: true, fretMin: 0, fretMax: 5, keyEnabled: true, keyTonic: 'E', keyMode: 'minor' });
const pcs = new Set([4, 6, 7, 9, 11, 0, 2]); // E minor natural: E F# G A B C D
expect(pool.length).toBeGreaterThan(0);
for (const midi of pool) {
expect(pcs.has(((midi % 12) + 12) % 12)).toBe(true);
}
// F (pc 5) and F# (pc 6 is in E minor; F natural is not) — F natural excluded
expect(pool.some(m => ((m % 12) + 12) % 12 === 5)).toBe(false);
});
it('supports modes beyond the single-note key signatures (dorian)', () => {
const pool = computeTargetPool({ ...base, keyEnabled: true, keyTonic: 'D', keyMode: 'dorian' });
const pcs = new Set([2, 4, 5, 7, 9, 11, 0]); // D dorian: D E F G A B C
expect(pool.length).toBeGreaterThan(0);
for (const midi of pool) {
expect(pcs.has(((midi % 12) + 12) % 12)).toBe(true);
}
});
it('falls back to major for an invalid mode id', () => {
const a = computeTargetPool({ ...base, keyEnabled: true, keyTonic: 'C', keyMode: 'major' });
const b = computeTargetPool({ ...base, keyEnabled: true, keyTonic: 'C', keyMode: 'bogus' as string });
expect(b).toEqual(a);
});
it('returns an empty pool rather than crashing for a non-fretted window request', () => {
const pool = computeTargetPool({ ...base, instrumentId: 'piano', fretWindowEnabled: true });
// piano has no fret window: window is ignored, base pool returned
expect(pool.length).toBeGreaterThan(0);
});
});

63
src/music/targetPool.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Unified target-note pool: base playable range (instrument + difficulty /
* fret window) intersected with the optional key-filter (pitch classes of a
* tonic+mode) and the optional fret window. Shared by all game modes so the
* controls behave identically everywhere.
*/
import { keyFor, type ModeId } from './scales';
import { computePlayableNotes, fretWindowNotes, isFrettedInstrument } from './playableRange';
import { TUNINGS } from './Tunings';
export interface TargetPoolInput {
readonly instrumentId: string;
readonly difficulty: string;
readonly tuningId: string;
readonly customMinFret?: number;
readonly customMaxFret?: number;
/** Key filter (target-note key, independent of the displayed key signature). */
readonly keyEnabled: boolean;
readonly keyTonic: string;
readonly keyMode: string;
/** Fret window (fretted instruments only). */
readonly fretWindowEnabled: boolean;
readonly fretMin: number;
readonly fretMax: number;
}
/**
* Compute the effective target-note pool.
*
* Order of application:
* 1. base playable range (instrument difficulty / custom frets)
* 2. fret window (replaces the range for fretted instruments — same
* semantics as Phrase Mode's fret window)
* 3. key filter (pitch-class intersection, keeps spelling/degree data intact
* via the same scale machinery Phrase Mode uses)
*/
export function computeTargetPool(input: TargetPoolInput): number[] {
const tuning = TUNINGS[input.tuningId];
const fretted = isFrettedInstrument(input.instrumentId) && !!tuning;
// 1+2: base pool with optional fret window
let base: number[];
if (input.fretWindowEnabled && fretted) {
base = fretWindowNotes(tuning!, input.fretMin, input.fretMax);
} else {
base = computePlayableNotes({
instrumentId: input.instrumentId,
difficulty: input.difficulty,
tuningId: input.tuningId,
customMinFret: input.customMinFret,
customMaxFret: input.customMaxFret,
});
}
if (!input.keyEnabled) return base;
// 3: key/mode pitch-class filter
const mode: ModeId = (['major', 'minor', 'dorian', 'phrygian', 'lydian', 'mixolydian', 'locrian', 'major-pentatonic', 'minor-pentatonic'] as const).includes(input.keyMode as ModeId)
? input.keyMode as ModeId
: 'major';
const key = keyFor(input.keyTonic, mode);
const pcs = new Set(key.pitchClasses);
return base.filter(midi => pcs.has(((midi % 12) + 12) % 12));
}

View File

@@ -1,5 +1,30 @@
export type Difficulty = string; export type Difficulty = string;
/** Unified target-note filter, shared by all game modes (v2 controls). */
export interface PracticeFilter {
/** Restrict target notes to pitch classes of this key/mode (null = off). */
keyEnabled: boolean;
keyTonic: string;
keyMode: string; // ModeId
/** Restrict target notes to a fret window (fretted instruments only). */
fretWindowEnabled: boolean;
fretMin: number;
fretMax: number;
}
export const DEFAULT_PRACTICE_FILTER: PracticeFilter = {
keyEnabled: false,
keyTonic: 'C',
keyMode: 'major',
fretWindowEnabled: false,
fretMin: 0,
fretMax: 4,
};
export function getPracticeFilter(s: AppSettings): PracticeFilter {
return { ...DEFAULT_PRACTICE_FILTER, ...(s.practice ?? {}) };
}
export interface RhythmSettings { export interface RhythmSettings {
mode: 'bpm' | 'seconds'; mode: 'bpm' | 'seconds';
bpm: number; bpm: number;
@@ -68,6 +93,8 @@ export function getPhraseSettings(s: AppSettings): PhraseSettings {
export interface AppSettings { export interface AppSettings {
phrase?: PhraseSettings; phrase?: PhraseSettings;
/** Unified target-note filter (key + fret window), all modes. */
practice?: PracticeFilter;
difficulty: Difficulty; difficulty: Difficulty;
showHint: boolean; showHint: boolean;
showFretboard: boolean; showFretboard: boolean;
@@ -114,6 +141,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
zenMode: false, zenMode: false,
gameMode: 'sight_reading', gameMode: 'sight_reading',
phrase: DEFAULT_PHRASE_SETTINGS, phrase: DEFAULT_PHRASE_SETTINGS,
practice: DEFAULT_PRACTICE_FILTER,
customMinFret: 0, customMinFret: 0,
customMaxFret: 12, customMaxFret: 12,
autoPlaySightReading: false, autoPlaySightReading: false,