Files
KGStudio/src/util/scaleUtil.ts
T
2025-12-17 23:07:21 -08:00

484 lines
17 KiB
TypeScript

import { KGCore } from '../core/KGCore';
import { pianoRollIndexToPitch } from './midiUtil';
import type { KeySignature } from '../core/KGProject';
/**
* Extracts the root note from a key signature string
* @param keySignature - Key signature like "C major", "F# minor", "Bb major"
* @returns Root note like "C", "F#", "Bb"
*/
export const getRootNoteFromKeySignature = (keySignature: KeySignature): string => {
// Extract the note before " major" or " minor"
const match = keySignature.match(/^([A-G][#b]?)\s+(major|minor)$/);
if (!match) {
console.warn(`Invalid key signature format: ${keySignature}, defaulting to C`);
return 'C';
}
return match[1];
};
/**
* Converts a note name (without octave) to pitch class (0-11)
* @param noteName - Note name like "C", "C#", "Db", "F#"
* @returns Pitch class (0=C, 1=C#/Db, 2=D, ..., 11=B)
*/
export const noteNameToPitchClass = (noteName: string): number => {
const noteMap: { [key: string]: number } = {
'C': 0, 'C#': 1, 'Db': 1,
'D': 2, 'D#': 3, 'Eb': 3,
'E': 4,
'F': 5, 'F#': 6, 'Gb': 6,
'G': 7, 'G#': 8, 'Ab': 8,
'A': 9, 'A#': 10, 'Bb': 10,
'B': 11
};
if (!(noteName in noteMap)) {
console.warn(`Invalid note name: ${noteName}, defaulting to C (0)`);
return 0;
}
return noteMap[noteName];
};
/**
* Calculates the pitch classes (0-11) that belong to a scale
* @param rootNote - Root note like "C", "F#", "Bb"
* @param modeSteps - Mode interval steps (e.g., [2, 2, 1, 2, 2, 2, 1] for ionian)
* @returns Array of pitch classes in the scale
*/
export const getScalePitchClasses = (rootNote: string, modeSteps: number[]): number[] => {
const rootPitchClass = noteNameToPitchClass(rootNote);
const scalePitchClasses: number[] = [rootPitchClass];
let currentPitch = rootPitchClass;
for (const step of modeSteps.slice(0, -1)) { // Exclude last step (returns to root)
currentPitch = (currentPitch + step) % 12;
scalePitchClasses.push(currentPitch);
}
return scalePitchClasses;
};
/**
* Gets the mode steps for a given mode id
* @param modeId - ID of the mode (e.g., "ionian", "aeolian")
* @returns Array of interval steps, or default ionian if not found
*/
export const getModeSteps = (modeId: string): number[] => {
const functionalChords = KGCore.FUNCTIONAL_CHORDS_DATA[modeId];
if (!functionalChords || !functionalChords.steps) {
console.warn(`Mode not found: ${modeId}, defaulting to ionian`);
return [2, 2, 1, 2, 2, 2, 1]; // Default to ionian (major scale)
}
return functionalChords.steps;
};
/**
* Transposes a note name by a given number of semitones
* @param noteName - Note name like "C", "C#", "Db"
* @param semitones - Number of semitones to transpose (positive or negative)
* @returns Transposed note name
*/
const transposeNote = (noteName: string, semitones: number): string => {
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const pitchClass = noteNameToPitchClass(noteName);
const newPitchClass = (pitchClass + semitones + 12) % 12;
return noteNames[newPitchClass];
};
/**
* Gets the list of suitable chords for a given key, mode, and functional harmony group
* @param keySignature - Key signature like "C major", "F# minor", "Bb major"
* @param modeId - Mode ID (e.g., "ionian", "aeolian", "dorian")
* @param functionType - Functional harmony group: "T" (Tonic), "S" (Subdominant), or "D" (Dominant)
* @returns Map of chord symbols to their transposed note arrays, e.g., { "I": ["C", "E", "G"], "vi": ["A", "C", "E"] }
* Returns empty object if mode or function not found
*
* Example:
* - getSuitableChords("C major", "ionian", "T") returns { "I": ["C", "E", "G"], "vi": ["A", "C", "E"], ... }
* - getSuitableChords("D major", "ionian", "T") returns { "I": ["D", "F#", "A"], "vi": ["B", "D", "F#"], ... }
*/
export const getSuitableChords = (
keySignature: KeySignature,
modeId: string,
functionType: 'T' | 'S' | 'D'
): Record<string, string[]> => {
// Get the functional chords for this mode
const functionalChords = KGCore.FUNCTIONAL_CHORDS_DATA[modeId];
if (!functionalChords) {
console.warn(`No functional chords found for mode: ${modeId}`);
return {};
}
// Get the chord symbols for the specified function (T/S/D)
const chordSymbols = functionalChords[functionType];
if (!chordSymbols || chordSymbols.length === 0) {
console.warn(`No chords found for function ${functionType} in mode ${modeId}`);
return {};
}
// Get the mode-specific chords data
const modeChords = functionalChords.chords;
if (!modeChords) {
console.warn(`No chords data found for mode: ${modeId}`);
return {};
}
// Get the root note from key signature
const rootNote = getRootNoteFromKeySignature(keySignature);
// Calculate the transposition interval from C to the root note
const transposeSemitones = noteNameToPitchClass(rootNote);
// Build a map of chord symbols to transposed notes
const chordMap: Record<string, string[]> = {};
for (const chordSymbol of chordSymbols) {
// Get the chord notes from mode-specific chords data (in C key)
const chordNotes = modeChords[chordSymbol];
if (!chordNotes) {
console.warn(`Chord symbol not found in mode ${modeId} chords: ${chordSymbol}`);
continue; // Skip this chord if not found
}
// If the key is C, use notes as-is; otherwise transpose
if (rootNote === 'C') {
chordMap[chordSymbol] = chordNotes;
} else {
// Transpose each note in the chord
const transposedNotes = chordNotes.map(note => transposeNote(note, transposeSemitones));
chordMap[chordSymbol] = transposedNotes;
}
}
return chordMap;
};
/**
* Gets the transposed notes for a specific chord in a given key and mode
* @param chordSymbol - Chord symbol (e.g., "I", "V7", "ii")
* @param keySignature - Key signature like "C major", "F# minor"
* @param modeId - Mode ID (e.g., "ionian", "aeolian")
* @returns Array of note names for the chord in the specified key, or empty array if not found
*/
export const getChordNotesInKey = (
chordSymbol: string,
keySignature: KeySignature,
modeId: string
): string[] => {
// Get the functional chords for this mode
const functionalChords = KGCore.FUNCTIONAL_CHORDS_DATA[modeId];
if (!functionalChords) {
console.warn(`No functional chords found for mode: ${modeId}`);
return [];
}
// Get the mode-specific chords data
const modeChords = functionalChords.chords;
if (!modeChords) {
console.warn(`No chords data found for mode: ${modeId}`);
return [];
}
// Get the root note from key signature
const rootNote = getRootNoteFromKeySignature(keySignature);
// Get the chord notes from mode-specific chords data (in C key)
const chordNotes = modeChords[chordSymbol];
if (!chordNotes) {
console.warn(`Chord symbol not found in mode ${modeId} chords: ${chordSymbol}`);
return [];
}
// If the key is C, return the notes as-is
if (rootNote === 'C') {
return chordNotes;
}
// Calculate the transposition interval from C to the root note
const transposeSemitones = noteNameToPitchClass(rootNote);
// Transpose each note in the chord
const transposedNotes = chordNotes.map(note => transposeNote(note, transposeSemitones));
return transposedNotes;
};
/**
* Gets matching chords for a given pitch, prioritized by which note in the chord matches
* @param hoverPitch - The MIDI pitch being hovered over (0-127)
* @param keySignature - Key signature like "C major", "F# minor"
* @param modeId - Mode ID (e.g., "ionian", "aeolian")
* @param functionType - Functional harmony group: "T" (Tonic), "S" (Subdominant), or "D" (Dominant)
* @returns Array of chord pitch class arrays, prioritized by match position
* (root matches first, then 2nd note matches, then 3rd note, etc.)
* Each chord is adjusted so its pitch classes work in the same octave as the hover pitch
*
* Example:
* - getMatchingChordsForPitch(60, "C major", "ionian", "T")
* Returns chords where C (pitch class 0) appears, with pitch classes adjusted for display
*/
export const getMatchingChordsForPitch = (
hoverPitch: number,
keySignature: KeySignature,
modeId: string,
functionType: 'T' | 'S' | 'D'
): number[][] => {
// Get suitable chords for the given key, mode, and function
const suitableChords = getSuitableChords(keySignature, modeId, functionType);
if (Object.keys(suitableChords).length === 0) {
return [];
}
// Convert chord note names to ascending pitch classes
const chordsPitchClasses: Record<string, number[]> = {};
for (const [chordSymbol, noteNames] of Object.entries(suitableChords)) {
const pitchClasses: number[] = [];
let previousPitch = -1;
for (const noteName of noteNames) {
let pitchClass = noteNameToPitchClass(noteName);
// Ensure ascending order by adding 12 if pitch class <= previous
if (previousPitch >= 0 && pitchClass <= previousPitch) {
pitchClass += 12;
}
pitchClasses.push(pitchClass);
previousPitch = pitchClass;
}
chordsPitchClasses[chordSymbol] = pitchClasses;
}
// Get the pitch class of the current hovering note (mod 12)
const hoverPitchClass = hoverPitch % 12;
// Group chords by which note position matches the hovering pitch class
// matchesByPosition[0] = chords where 1st note matches
// matchesByPosition[1] = chords where 2nd note matches, etc.
const matchesByPosition: number[][][] = [];
for (const pitchClasses of Object.values(chordsPitchClasses)) {
// Check each note position in the chord
for (let i = 0; i < pitchClasses.length; i++) {
if (pitchClasses[i] % 12 === hoverPitchClass) {
// Ensure the array exists for this position
if (!matchesByPosition[i]) {
matchesByPosition[i] = [];
}
// Offset the chord based on the matched pitch class
// If the matched note is >= 12 (in the second octave), shift all notes down by 12
if (pitchClasses[i] >= 12) {
const offsetChord = pitchClasses.map(p => p - 12);
matchesByPosition[i].push(offsetChord);
} else {
matchesByPosition[i].push(pitchClasses);
}
break; // Only count each chord once (at first matching position)
}
}
}
// Flatten the grouped matches: 1st position matches first, then 2nd, then 3rd, etc.
const result: number[][] = [];
for (const matches of matchesByPosition) {
if (matches) {
result.push(...matches);
}
}
return result;
};
/**
* Generates the CSS background-image string for the piano grid with scale highlighting
* @param selectedMode - Current mode ID (e.g., "ionian", "dorian")
* @param keySignature - Current key signature (e.g., "C major", "F# minor")
* @returns CSS background-image string with highlighted scale notes
*/
export const generatePianoGridBackground = (
selectedMode: string,
keySignature: KeySignature
): string => {
// Get root note and scale pitch classes
const rootNote = getRootNoteFromKeySignature(keySignature);
const modeSteps = getModeSteps(selectedMode);
const scalePitchClasses = getScalePitchClasses(rootNote, modeSteps);
// Generate horizontal lines for each of 96 rows (8 octaves)
const horizontalLines = Array.from({ length: 96 }, (_, index) => {
const pitch = pianoRollIndexToPitch(index);
const pitchClass = pitch % 12;
const isInScale = scalePitchClasses.includes(pitchClass);
// Calculate row positions using CSS calc() with --region-piano-key-height variable
const rowTop = `calc(var(--region-piano-key-height) * ${index})`;
const rowBottomMinusOne = `calc(var(--region-piano-key-height) * ${index + 1} - 1px)`;
const rowBottom = `calc(var(--region-piano-key-height) * ${index + 1})`;
// For scale notes: highlight the full row with a semi-transparent blue background
// For non-scale notes: use transparent background with just the separator line
if (isInScale) {
return `
rgba(90, 123, 154, 0.15) ${rowTop},
rgba(90, 123, 154, 0.15) ${rowBottomMinusOne},
#3a3a3a ${rowBottomMinusOne},
#3a3a3a ${rowBottom}
`.trim();
} else {
return `
transparent ${rowTop},
transparent ${rowBottomMinusOne},
#3a3a3a ${rowBottomMinusOne},
#3a3a3a ${rowBottom}
`.trim();
}
}).join(',\n');
// Return complete background-image with vertical and horizontal gradients
// Note: Vertical beat lines gradient should be preserved from existing CSS
return `
linear-gradient(to right,
transparent calc(var(--region-grid-beat-width) - 1px),
#3a3a3a calc(var(--region-grid-beat-width) - 1px),
#3a3a3a var(--region-grid-beat-width)
),
linear-gradient(to bottom, ${horizontalLines})
`;
};
/**
* Validation result for functional chords JSON
*/
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* Validates functional chords JSON structure
* @param jsonString - JSON string to validate
* @returns Validation result with errors if any
*/
export const validateFunctionalChordsJSON = (jsonString: string): ValidationResult => {
const errors: string[] = [];
// Try to parse JSON
let data: unknown;
try {
data = JSON.parse(jsonString);
} catch (error) {
return { valid: false, errors: ['Invalid JSON format'] };
}
// Check if data is an object
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
return { valid: false, errors: ['Root must be an object'] };
}
// Type guard to treat data as a record
const dataRecord = data as Record<string, unknown>;
// Check if ionian mode exists
if (!dataRecord.ionian) {
errors.push('Missing required mode: "ionian"');
}
// Regex patterns
const namePattern = /^[A-Za-z0-9_\- ]+$/;
const romanNumeralPattern = /^♭?[ivIV]+[⁶°+0-9]*$/;
const notePattern = /^[A-G][b#]?$/;
// Validate each mode
for (const [modeId, modeData] of Object.entries(dataRecord)) {
const modePrefix = `Mode "${modeId}"`;
// Validate mode structure
if (typeof modeData !== 'object' || modeData === null || Array.isArray(modeData)) {
errors.push(`${modePrefix}: must be an object`);
continue;
}
// Type guard for mode object
const mode = modeData as Record<string, unknown>;
// Validate name
if (typeof mode.name !== 'string' || !namePattern.test(mode.name)) {
errors.push(`${modePrefix}: "name" must be a string with letters, numbers, underscores, dashes, and spaces`);
}
// Validate steps
if (!Array.isArray(mode.steps)) {
errors.push(`${modePrefix}: "steps" must be an array`);
} else {
if (mode.steps.length !== 7) {
errors.push(`${modePrefix}: "steps" must contain exactly 7 integers`);
}
if (!mode.steps.every((step: unknown) => Number.isInteger(step))) {
errors.push(`${modePrefix}: "steps" must contain only integers`);
}
const sum = mode.steps.reduce((acc: number, val: unknown) => acc + (typeof val === 'number' ? val : 0), 0);
if (sum !== 12) {
errors.push(`${modePrefix}: "steps" must sum to 12 (got ${sum})`);
}
}
// Collect all chord symbols from T, S, D
const allChordSymbols = new Set<string>();
// Validate T, S, D arrays
for (const functionType of ['T', 'S', 'D']) {
if (!Array.isArray(mode[functionType])) {
errors.push(`${modePrefix}: "${functionType}" must be an array`);
continue;
}
for (const chordSymbol of mode[functionType]) {
if (typeof chordSymbol !== 'string' || !romanNumeralPattern.test(chordSymbol)) {
errors.push(`${modePrefix}: Invalid chord symbol "${chordSymbol}" in "${functionType}" (must be Roman numeral I-VII with optional ♭ prefix and/or ⁶°+digit suffixes)`);
}
allChordSymbols.add(chordSymbol);
}
}
// Validate chords object
if (typeof mode.chords !== 'object' || mode.chords === null || Array.isArray(mode.chords)) {
errors.push(`${modePrefix}: "chords" must be an object`);
continue;
}
// Type guard for chords object
const chords = mode.chords as Record<string, unknown>;
// Check if all chord symbols are defined in chords
for (const chordSymbol of allChordSymbols) {
if (!(chordSymbol in chords)) {
errors.push(`${modePrefix}: Chord "${chordSymbol}" referenced in T/S/D but not defined in "chords"`);
}
}
// Validate each chord definition
for (const [chordSymbol, chordNotes] of Object.entries(chords)) {
if (!Array.isArray(chordNotes)) {
errors.push(`${modePrefix}: Chord "${chordSymbol}" must be an array of notes`);
continue;
}
for (const note of chordNotes) {
if (typeof note !== 'string' || !notePattern.test(note)) {
errors.push(`${modePrefix}: Invalid note "${note}" in chord "${chordSymbol}" (must be A-G with optional b or #)`);
}
}
}
}
return {
valid: errors.length === 0,
errors
};
};