mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
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:
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
119
src/components/TargetNoteControls.tsx
Normal file
119
src/components/TargetNoteControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
}
|
|
||||||
|
|
||||||
if (config.type === 'custom_fret') {
|
|
||||||
if (currentTuning) {
|
|
||||||
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);
|
}, [settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret,
|
||||||
}
|
settings.practice?.keyEnabled, settings.practice?.keyTonic, settings.practice?.keyMode,
|
||||||
return [];
|
settings.practice?.fretWindowEnabled, settings.practice?.fretMin, settings.practice?.fretMax]);
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
78
src/music/targetPool.test.ts
Normal file
78
src/music/targetPool.test.ts
Normal 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
63
src/music/targetPool.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user