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 => { // 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 = {}; 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 = {}; 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; // 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; // 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(); // 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; // 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 }; };