484 lines
17 KiB
TypeScript
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
|
|
};
|
|
};
|