mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode P0: score model, scales/keys, playable range, melody + scale generators (tested)
This commit is contained in:
130
src/music/scaleDrills.ts
Normal file
130
src/music/scaleDrills.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Scale drill generator (Phrase Mode) — required material, contract C4.
|
||||
*
|
||||
* Produces scale runs (up / down / up-and-down) as quarter notes with the
|
||||
* final note extended (with ties if needed) to complete its bar. One- and
|
||||
* two-octave drills require the COMPLETE scale path to fit the playable
|
||||
* pool — an incomplete "one-octave scale" is never emitted; a clear,
|
||||
* actionable error is returned instead.
|
||||
*/
|
||||
import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model';
|
||||
import { keyFor, scalePitches, type KeyContext, type ModeId } from './scales';
|
||||
|
||||
export type DrillDirection = 'up' | 'down' | 'updown';
|
||||
export type DrillCoverage = 'one-octave' | 'two-octave' | 'position';
|
||||
|
||||
export interface DrillConfig {
|
||||
readonly keyTonic: string;
|
||||
readonly keyMode: ModeId;
|
||||
readonly direction: DrillDirection;
|
||||
readonly coverage: DrillCoverage;
|
||||
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
|
||||
}
|
||||
|
||||
/** Complete ascending scale path from a starting tonic, if it fits the pool. */
|
||||
function octavePath(key: KeyContext, pool: number[], octaves: number, startMidi: number): number[] | null {
|
||||
const set = new Set(pool);
|
||||
const path: number[] = [startMidi];
|
||||
let cur = startMidi;
|
||||
for (let o = 0; o < octaves; o++) {
|
||||
// Skip the tonic at the start of each octave (it is already in `path`).
|
||||
for (let i = (o === 0 ? 1 : 0); i < key.pitchClasses.length; i++) {
|
||||
const pc = key.pitchClasses[i];
|
||||
const offset = ((pc - (cur % 12)) + 12) % 12;
|
||||
const candidate = cur + (offset === 0 ? 12 : offset);
|
||||
if (!set.has(candidate)) return null;
|
||||
path.push(candidate);
|
||||
cur = candidate;
|
||||
}
|
||||
}
|
||||
// Top tonic completes the requested octave span.
|
||||
if (!set.has(startMidi + 12 * octaves)) return null;
|
||||
path.push(startMidi + 12 * octaves);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function generateScaleDrill(
|
||||
config: DrillConfig,
|
||||
eligiblePitches: number[],
|
||||
): Result<Score> {
|
||||
const key = keyFor(config.keyTonic, config.keyMode);
|
||||
const pool = Array.from(new Set(eligiblePitches.filter(m => Number.isInteger(m)))).sort((a, b) => a - b);
|
||||
if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' };
|
||||
|
||||
const scaleInPool = scalePitches(key, pool[0], pool[pool.length - 1]).filter(p => pool.includes(p.midi));
|
||||
if (scaleInPool.length === 0) return { ok: false, error: 'The selected key has no notes in the playable range — widen the range or change the key.' };
|
||||
|
||||
let path: number[];
|
||||
|
||||
if (config.coverage === 'position') {
|
||||
// Traverse the eligible scale notes in order (may start/end away from
|
||||
// the tonic — labeled as such in the UI, not a conventional fingering).
|
||||
const asc = scaleInPool.map(p => p.midi);
|
||||
if (asc.length < 2) return { ok: false, error: 'The selected position contains fewer than two scale notes — widen the fret window or change the key.' };
|
||||
if (config.direction === 'up') path = asc;
|
||||
else if (config.direction === 'down') path = [...asc].reverse();
|
||||
else path = [...asc, ...[...asc].reverse().slice(1)]; // up-down, no duplicate top
|
||||
} else {
|
||||
const octaves = config.coverage === 'one-octave' ? 1 : 2;
|
||||
// Find the lowest tonic start whose complete path fits the pool.
|
||||
const tonics = scaleInPool.filter(p => p.pitch.midi % 12 === key.pitchClasses[0]).map(p => p.midi);
|
||||
let found: number[] | null = null;
|
||||
for (const t of tonics) {
|
||||
found = octavePath(key, pool, octaves, t);
|
||||
if (found) break;
|
||||
}
|
||||
if (!found) {
|
||||
return {
|
||||
ok: false,
|
||||
error: octaves === 1
|
||||
? 'The complete one-octave scale does not fit the playable range — widen the range, or use the "within position" drill.'
|
||||
: 'The complete two-octave scale does not fit the playable range — use one octave or widen the range.',
|
||||
};
|
||||
}
|
||||
const up = found;
|
||||
if (config.direction === 'up') path = up;
|
||||
else if (config.direction === 'down') path = [...up].reverse();
|
||||
else path = [...up, ...[...up].reverse().slice(1)]; // no duplicate turning tonic
|
||||
}
|
||||
|
||||
// ---- Quarter-note rhythm, final note extended to fill its bar ---------
|
||||
const full = config.meter.numerator * PPQ;
|
||||
if (path.length * PPQ > 8 * full) {
|
||||
return { ok: false, error: 'This drill is longer than 8 bars — reduce the coverage or direction.' };
|
||||
}
|
||||
const barCount = Math.max(1, Math.ceil((path.length * PPQ) / full));
|
||||
const totalTicks = barCount * full;
|
||||
const lastDuration = PPQ + (totalTicks - path.length * PPQ);
|
||||
|
||||
const events: ScoreEvent[] = [];
|
||||
let cursor = 0;
|
||||
const spellFor = (midi: number) => {
|
||||
const sp = scalePitches(key, midi, midi);
|
||||
const found = sp.find(p => p.midi === midi && pool.includes(p.midi));
|
||||
return found ? found.pitch : sp[0]?.pitch ?? null;
|
||||
};
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const dur = i === path.length - 1 ? lastDuration : PPQ;
|
||||
const pitch = spellFor(path[i]);
|
||||
if (!pitch) return { ok: false, error: 'Internal error spelling drill pitch.' };
|
||||
events.push({ id: `s${i}`, startTick: cursor, durationTicks: dur, pitch });
|
||||
cursor += dur;
|
||||
}
|
||||
|
||||
const score: Score = {
|
||||
version: 1,
|
||||
id: `scale-${config.keyTonic}-${config.keyMode}-${config.coverage}-${config.direction}-${config.meter.numerator}4`,
|
||||
title: `${key.tonic} scale — ${config.coverage === 'position' ? 'within position' : config.coverage} ${config.direction}`,
|
||||
meter: config.meter,
|
||||
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
|
||||
measures: Array.from({ length: barCount }, (_, i) => ({
|
||||
number: i + 1,
|
||||
startTick: i * full,
|
||||
durationTicks: full,
|
||||
})),
|
||||
voices: [{ id: 'melody', events }],
|
||||
};
|
||||
const problems = validateScore(score);
|
||||
if (problems.length > 0) return { ok: false, error: `Internal drill error: ${problems[0]}` };
|
||||
return { ok: true, value: score };
|
||||
}
|
||||
Reference in New Issue
Block a user