+
+
);
};
+
+// Re-export keyFor for potential external use (avoids unused-import lint)
+void keyFor;
diff --git a/src/hooks/useGameLogic.ts b/src/hooks/useGameLogic.ts
index 3b03631..75e27ba 100644
--- a/src/hooks/useGameLogic.ts
+++ b/src/hooks/useGameLogic.ts
@@ -44,13 +44,12 @@ export const useGameLogic = (
keyEnabled: pf.keyEnabled,
keyTonic: pf.keyTonic,
keyMode: pf.keyMode,
+ strings: pf.strings,
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]);
+ }, [settings]);
// --- Note Generation ---
const generateNewNote = useCallback((keepFeedback = false) => {
diff --git a/src/music/targetPool.test.ts b/src/music/targetPool.test.ts
index 5d8e518..531fcc8 100644
--- a/src/music/targetPool.test.ts
+++ b/src/music/targetPool.test.ts
@@ -10,6 +10,7 @@ const base = {
keyEnabled: false,
keyTonic: 'C',
keyMode: 'major',
+ strings: [],
fretWindowEnabled: false,
fretMin: 0,
fretMax: 4,
@@ -76,3 +77,34 @@ describe('computeTargetPool', () => {
expect(pool.length).toBeGreaterThan(0);
});
});
+
+describe('computeTargetPool — string selection', () => {
+ it('restricts to a single string when selected', () => {
+ // String index 5 = high E (open midi 64)
+ const pool = computeTargetPool({ ...base, strings: [5] });
+ expect(pool).toContain(64); // open high E
+ expect(pool).toContain(65); // fret 1
+ expect(pool).not.toContain(45); // open A (string 1) — different string
+ // all notes come from one string
+ expect(pool.every(m => m >= 64)).toBe(true);
+ });
+
+ it('combines strings + fret window', () => {
+ // String index 1 (open A = 45), frets 0..2
+ const pool = computeTargetPool({ ...base, strings: [1], fretWindowEnabled: true, fretMin: 0, fretMax: 2 });
+ expect(pool).toEqual([45, 46, 47]);
+ });
+
+ it('combines strings + fret window + key filter', () => {
+ // Low E string (index 0), frets 0..4, C major → only natural notes in that range
+ const pool = computeTargetPool({ ...base, strings: [0], fretWindowEnabled: true, fretMin: 0, fretMax: 4, keyEnabled: true, keyTonic: 'C', keyMode: 'major' });
+ // Low E string frets 0-4: E(40) F(41) F#(42) G(43) G#(44); C major pcs: C D E F G A B
+ expect(pool).toEqual([40, 41, 43]); // E, F, G (F#, G# excluded)
+ });
+
+ it('treats empty strings array as all strings', () => {
+ const a = computeTargetPool({ ...base, strings: [] });
+ const b = computeTargetPool({ ...base, strings: [0, 1, 2, 3, 4, 5] });
+ expect(a).toEqual(b);
+ });
+});
diff --git a/src/music/targetPool.ts b/src/music/targetPool.ts
index 84c22b8..c1aa5e3 100644
--- a/src/music/targetPool.ts
+++ b/src/music/targetPool.ts
@@ -5,7 +5,7 @@
* controls behave identically everywhere.
*/
import { keyFor, type ModeId } from './scales';
-import { computePlayableNotes, fretWindowNotes, isFrettedInstrument } from './playableRange';
+import { computePlayableNotes, isFrettedInstrument } from './playableRange';
import { TUNINGS } from './Tunings';
export interface TargetPoolInput {
@@ -18,6 +18,8 @@ export interface TargetPoolInput {
readonly keyEnabled: boolean;
readonly keyTonic: string;
readonly keyMode: string;
+ /** Selected string indices (0 = lowest). Empty = all strings. */
+ readonly strings: number[];
/** Fret window (fretted instruments only). */
readonly fretWindowEnabled: boolean;
readonly fretMin: number;
@@ -29,8 +31,8 @@ export interface TargetPoolInput {
*
* 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)
+ * 2. string selection ∩ fret window (fretted instruments only — replaces
+ * the range, 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)
*/
@@ -38,10 +40,21 @@ 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
+ // 1+2: base pool with string selection and optional fret window
let base: number[];
- if (input.fretWindowEnabled && fretted) {
- base = fretWindowNotes(tuning!, input.fretMin, input.fretMax);
+ if (fretted) {
+ const all = tuning!.strings;
+ const selected = input.strings.length > 0
+ ? all.filter((_, i) => input.strings.includes(i))
+ : all;
+ if (selected.length === 0) return [];
+ const lo = input.fretWindowEnabled ? Math.max(0, Math.min(24, input.fretMin)) : 0;
+ const hi = input.fretWindowEnabled ? Math.max(lo, Math.min(24, input.fretMax)) : 24;
+ base = selected.flatMap(open => {
+ const notes: number[] = [];
+ for (let fret = lo; fret <= hi; fret++) notes.push(open + fret);
+ return notes;
+ }).sort((a, b) => a - b);
} else {
base = computePlayableNotes({
instrumentId: input.instrumentId,
diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts
index e8c2535..f939f95 100644
--- a/src/types/SettingsTypes.ts
+++ b/src/types/SettingsTypes.ts
@@ -6,7 +6,10 @@ export interface PracticeFilter {
keyEnabled: boolean;
keyTonic: string;
keyMode: string; // ModeId
- /** Restrict target notes to a fret window (fretted instruments only). */
+ /** Restrict target notes to selected strings (fretted instruments only).
+ * Empty array = no restriction (all strings). Index 0 = lowest string. */
+ strings: number[];
+ /** Restrict target notes to a fret range (fretted instruments only). */
fretWindowEnabled: boolean;
fretMin: number;
fretMax: number;
@@ -16,6 +19,7 @@ export const DEFAULT_PRACTICE_FILTER: PracticeFilter = {
keyEnabled: false,
keyTonic: 'C',
keyMode: 'major',
+ strings: [],
fretWindowEnabled: false,
fretMin: 0,
fretMax: 4,
@@ -101,6 +105,8 @@ export interface AppSettings {
showTuningMeter: boolean;
tuningId: string;
keySignature: string;
+ /** Keep display key signature in sync with the target-key control. */
+ keyFollowsTarget?: boolean;
instrument: string;
rhythm: RhythmSettings;
zenMode: boolean;
@@ -128,6 +134,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
showTuningMeter: false,
tuningId: 'standard',
keySignature: 'C',
+ keyFollowsTarget: false,
instrument: 'guitar',
rhythm: {
mode: 'bpm',