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 { getTempoMarking } from '../music/TempoMarkings';
|
||||
import { TuningMeter } from './TuningMeter';
|
||||
import { TargetNoteControls } from './TargetNoteControls';
|
||||
|
||||
import { useSettings } from '../context/useSettings';
|
||||
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
|
||||
@@ -193,6 +194,9 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target-note filter: unified controls, all modes */}
|
||||
<TargetNoteControls />
|
||||
|
||||
{/* Bottom Section: Tools Grid (2 Columns now) */}
|
||||
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
|
||||
{/* Tool 1: Tuner */}
|
||||
|
||||
@@ -13,13 +13,13 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
|
||||
import { useSettings } from '../context/useSettings';
|
||||
import { useAudioPlayer } from '../hooks/useAudioPlayer';
|
||||
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 { audioEngine } from '../audio/AudioEngine';
|
||||
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange';
|
||||
import { generateMelody } from '../music/melodyGenerator';
|
||||
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 { getNoteDetails } from '../music/NoteUtils';
|
||||
import { EXERCISES, loadExercise } from '../exercises/library';
|
||||
@@ -68,8 +68,27 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
const setSettings = updateSettings;
|
||||
const { playNote } = useAudioPlayer();
|
||||
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>) => {
|
||||
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]);
|
||||
|
||||
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||
@@ -489,22 +508,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</label>
|
||||
|
||||
{(phrase.material === 'melody' || phrase.material === 'scale') && (
|
||||
<>
|
||||
<label>Key
|
||||
<select value={phrase.keyTonic} onChange={e => setPhrase({ keyTonic: e.target.value })}>
|
||||
{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>
|
||||
</>
|
||||
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
|
||||
Key/mode: use the "Target key" controls below the sheet — shared across modes.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{phrase.material === 'melody' && (
|
||||
@@ -604,28 +610,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
)}
|
||||
|
||||
{fretted && (
|
||||
<>
|
||||
<label className="phrase-check">
|
||||
<input
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
|
||||
Fret window: use the "Fret window" controls below the sheet — shared across modes.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<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,
|
||||
getNoteDetails
|
||||
} from '../music/NoteUtils';
|
||||
import {
|
||||
TUNINGS,
|
||||
getOpenStringNotes,
|
||||
getFirstPositionNotes,
|
||||
} from '../music/Tunings';
|
||||
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
||||
import { computeTargetPool } from '../music/targetPool';
|
||||
import { getPracticeFilter } from '../types/SettingsTypes';
|
||||
import {
|
||||
NOTE_MATCH_THRESHOLD_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));
|
||||
|
||||
// --- Valid Notes Calculation ---
|
||||
const currentTuning = TUNINGS[settings.tuningId];
|
||||
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||
|
||||
const validNotes = useMemo(() => {
|
||||
const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
||||
|
||||
const getNotesFromConfig = (config: typeof rangeConfig) => {
|
||||
if (!config) return [];
|
||||
|
||||
if (config.type === 'open_strings') {
|
||||
if (currentTuning) return getOpenStringNotes(currentTuning);
|
||||
return config.notes || [];
|
||||
}
|
||||
|
||||
if (config.type === 'first_position') {
|
||||
if (currentTuning) return getFirstPositionNotes(currentTuning);
|
||||
return config.notes || [];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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]);
|
||||
const pf = getPracticeFilter(settings);
|
||||
return computeTargetPool({
|
||||
instrumentId: settings.instrument,
|
||||
difficulty: settings.difficulty,
|
||||
tuningId: settings.tuningId,
|
||||
customMinFret: settings.customMinFret,
|
||||
customMaxFret: settings.customMaxFret,
|
||||
keyEnabled: pf.keyEnabled,
|
||||
keyTonic: pf.keyTonic,
|
||||
keyMode: pf.keyMode,
|
||||
fretWindowEnabled: pf.fretWindowEnabled,
|
||||
fretMin: pf.fretMin,
|
||||
fretMax: pf.fretMax,
|
||||
});
|
||||
}, [settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret,
|
||||
settings.practice?.keyEnabled, settings.practice?.keyTonic, settings.practice?.keyMode,
|
||||
settings.practice?.fretWindowEnabled, settings.practice?.fretMin, settings.practice?.fretMax]);
|
||||
|
||||
// --- Note Generation ---
|
||||
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;
|
||||
|
||||
/** 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 {
|
||||
mode: 'bpm' | 'seconds';
|
||||
bpm: number;
|
||||
@@ -68,6 +93,8 @@ export function getPhraseSettings(s: AppSettings): PhraseSettings {
|
||||
|
||||
export interface AppSettings {
|
||||
phrase?: PhraseSettings;
|
||||
/** Unified target-note filter (key + fret window), all modes. */
|
||||
practice?: PracticeFilter;
|
||||
difficulty: Difficulty;
|
||||
showHint: boolean;
|
||||
showFretboard: boolean;
|
||||
@@ -114,6 +141,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
zenMode: false,
|
||||
gameMode: 'sight_reading',
|
||||
phrase: DEFAULT_PHRASE_SETTINGS,
|
||||
practice: DEFAULT_PRACTICE_FILTER,
|
||||
customMinFret: 0,
|
||||
customMaxFret: 12,
|
||||
autoPlaySightReading: false,
|
||||
|
||||
Reference in New Issue
Block a user