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:
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));
|
||||
}
|
||||
Reference in New Issue
Block a user