# ============================================ # Simple chord + melody analysis helper # ============================================ from typing import List, Dict, Any # -------------------------------------------- # Basic pitch-class helpers # -------------------------------------------- NOTE_TO_PC = { "C": 0, "B#": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, "E": 4, "Fb": 4, "F": 5, "E#": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11, "Cb": 11, } PC_TO_SHARP_NAME = { 0: "C", 1: "C#", 2: "D", 3: "Eb", 4: "E", 5: "F", 6: "F#", 7: "G", 8: "Ab", 9: "A", 10: "Bb", 11: "B", } def normalize_note(name: str) -> str: """Normalize note name (upper-case, strip spaces).""" return name.strip().replace('♭', 'b').replace('♯', '#').upper() def note_to_pc(name: str) -> int: name = normalize_note(name) if name not in NOTE_TO_PC: raise ValueError(f"Unknown note name: {name}") return NOTE_TO_PC[name] # -------------------------------------------- # Scales and modes # -------------------------------------------- # Major scale intervals from tonic: 1–7 MAJOR_INTERVALS = [0, 2, 4, 5, 7, 9, 11] MODE_NAMES = { 1: "Ionian (major)", 2: "Dorian", 3: "Phrygian", 4: "Lydian", 5: "Mixolydian", 6: "Aeolian (natural minor)", 7: "Locrian", } def build_major_scale_pcs(tonic: str) -> List[int]: t_pc = note_to_pc(tonic) return [(t_pc + i) % 12 for i in MAJOR_INTERVALS] def rotate_intervals(mode_degree: int) -> List[int]: """Return intervals (0–11) for a mode, relative to its tonic, built by rotating the major scale intervals.""" # Rotate major intervals so that mode_degree becomes 1 shifted = MAJOR_INTERVALS[mode_degree - 1:] + MAJOR_INTERVALS[:mode_degree - 1] tonic_offset = shifted[0] return [ (i - tonic_offset) % 12 for i in shifted ] # -------------------------------------------- # Chord parsing # -------------------------------------------- def parse_chord_symbol(symbol: str) -> Dict[str, Any]: """ Very simple parser. Supports roots like 'G', 'Gb', 'F#' and qualities like: - maj7, M7 - m, m7 - 7, 9, 13, b9, #9, b13, #11, etc (dominant) """ s = symbol.strip() s = s.replace('♭', 'b').replace('♯', '#') # root: first letter + optional #/b if len(s) >= 2 and s[1] in ['#', 'b']: root = s[:2] rest = s[2:] else: root = s[0] rest = s[1:] root = normalize_note(root) quality = "maj" # default dominant = False minor = False maj7 = False extensions = [] r_lower = rest.lower() if "maj" in r_lower or "ma7" in r_lower or "maj7" in r_lower or "∆" in r_lower: quality = "maj" maj7 = True elif "m" in r_lower and "maj" not in r_lower: # crude: 'm' means minor quality = "min" minor = True elif "7" in r_lower or "9" in r_lower or "13" in r_lower: quality = "dom" dominant = True # Dominant chord if has 7/9/13 but not maj or minor # Collect alterations (b9, #9, b13, #11, etc) for alt in ["b9", "#9", "b13", "#11"]: if alt in r_lower: extensions.append(alt) return { "symbol": symbol, "root": root, "quality": quality, # 'maj', 'min', 'dom' "dominant": dominant, "minor": minor, "maj7": maj7, "extensions": extensions, } # -------------------------------------------- # Roman numeral + mode in global key # -------------------------------------------- def get_degree_in_key(root: str, global_key: str) -> int: """Return scale degree (1–7) of root in global major key, or 0 if not diatonic.""" pcs = build_major_scale_pcs(global_key) root_pc = note_to_pc(root) if root_pc in pcs: return pcs.index(root_pc) + 1 return 0 def roman_for_degree(degree: int, chord_quality: str) -> str: romans = {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", 7: "VII"} if degree == 0: return "N/A" base = romans[degree] if chord_quality == "maj": return base elif chord_quality == "min": return base.lower() elif chord_quality == "dom": return base # dominant often uppercase else: return base def mode_for_degree(degree: int) -> str: return MODE_NAMES.get(degree, "Non-diatonic") def detect_tritone_sub(chord_info: Dict[str, Any], global_key: str) -> str: """Very conservative: bII7 in major key = tritone sub of V.""" if not chord_info["dominant"]: return "" tonic_pc = note_to_pc(global_key) b2_pc = (tonic_pc + 1) % 12 root_pc = note_to_pc(chord_info["root"]) if root_pc == b2_pc: return "Tritone substitution of V7" return "" def detect_modal_borrowing(degree: int, chord_info: Dict[str, Any]) -> str: """Very simple: minor iv in major key.""" if degree == 4 and chord_info["minor"]: return "Borrowed from parallel minor (iv)" return "" # -------------------------------------------- # Map melody notes to chord-scale degrees # -------------------------------------------- DEGREE_LABELS = { 0: "1", 1: "b2", 2: "2", 3: "b3", 4: "3", 5: "4", 6: "b5", 7: "5", 8: "#5", 9: "6", 10: "b7", 11: "7", } def build_mode_scale(root: str, degree_in_key: int) -> List[int]: """ Build a 7-note mode scale for the chord, using the church mode associated with its scale degree in the global key. """ root_pc = note_to_pc(root) # mode built from root with appropriate interval pattern intervals = rotate_intervals(degree_in_key if degree_in_key != 0 else 1) return [(root_pc + i) % 12 for i in intervals] def pitch_to_chord_degree(pc: int, chord_root_pc: int) -> str: """Return scale-degree label (1, b2, 2, b3, 3, ..., 7) relative to chord root.""" semitone = (pc - chord_root_pc) % 12 return DEGREE_LABELS.get(semitone, f"?({semitone})") # -------------------------------------------- # Melody function classification # -------------------------------------------- def analyze_melody_functions( melody_pcs: List[int], chord_scale_pcs: List[int], chord_root_pc: int, ) -> Dict[int, List[str]]: """ VERY simple heuristic classification of notes in a single-chord context. Returns dict: index -> [labels]. Labels: 'guide_tone', 'arpeggio', 'scale_line', 'upper_neighbor', 'lower_neighbor', 'passing', 'chromatic_passing' """ n = len(melody_pcs) labels: Dict[int, List[str]] = {i: [] for i in range(n)} # Identify chord tones (1,3,5,7) in this mode (use major-ish mapping) chord_tone_semitones = [0, 4, 7, 11] # 1,3,5,7 above root in semitones chord_tones = [ (chord_root_pc + x) % 12 for x in chord_tone_semitones ] guide_tones = [ (chord_root_pc + 4) % 12, (chord_root_pc + 11) % 12 ] # 3 & 7 for i, pc in enumerate(melody_pcs): if pc in guide_tones: labels[i].append("guide_tone") elif pc in chord_tones: labels[i].append("chord_tone") # Scale lines: three consecutive stepwise notes for i in range(1, n - 1): a, b, c = melody_pcs[i - 1], melody_pcs[i], melody_pcs[i + 1] d1 = (b - a) % 12 d2 = (c - b) % 12 if d1 in (1, 2, 10, 11) and d2 == d1: # crude ascending/descending step labels[i - 1].append("scale_line") labels[i].append("scale_line") labels[i + 1].append("scale_line") # Neighbor tones (upper / lower) for i in range(1, n - 1): a, b, c = melody_pcs[i - 1], melody_pcs[i], melody_pcs[i + 1] if a == c and a in chord_tones: step = (b - a) % 12 if step in (1, 2): # up labels[i].append("upper_neighbor") elif step in (10, 11): # down labels[i].append("lower_neighbor") # Passing and chromatic passing for i in range(1, n - 1): a, b, c = melody_pcs[i - 1], melody_pcs[i], melody_pcs[i + 1] # require chord tones on ends, non-chord in middle if a in chord_tones and c in chord_tones and b not in chord_tones: up = (b - a) % 12 down = (c - b) % 12 if up in (1, 2, 10, 11) and down in (1, 2, 10, 11): # if b in chord scale -> passing; else chromatic passing if b in chord_scale_pcs: labels[i].append("passing_tone") else: labels[i].append("chromatic_passing_tone") return labels # -------------------------------------------- # Main entry function # -------------------------------------------- def analyze_chord_and_melody( chord_symbol: str, global_key: str, melody_notes: List[str], ) -> Dict[str, Any]: """ Main function user will call. Returns a dict with: - 'chord_info' - 'degree_in_key' - 'roman' - 'mode' - 'tritone_sub', 'modal_borrowing' - 'note_degrees' (list of degree labels per melody note) - 'functions' (index -> [labels]) """ chord_info = parse_chord_symbol(chord_symbol) degree = get_degree_in_key(chord_info["root"], global_key) roman = roman_for_degree(degree, chord_info["quality"]) mode_name = mode_for_degree(degree) tri_sub = detect_tritone_sub(chord_info, global_key) modal_borrow = detect_modal_borrowing(degree, chord_info) # Build chord-scale as mode from chord root if degree == 0: # non-diatonic: just use major scale from root as crude fallback chord_scale_pcs = build_major_scale_pcs(chord_info["root"]) else: chord_scale_pcs = build_mode_scale(chord_info["root"], degree) chord_root_pc = note_to_pc(chord_info["root"]) melody_pcs = [note_to_pc(n) for n in melody_notes] note_degrees = [ pitch_to_chord_degree(pc, chord_root_pc) for pc in melody_pcs ] functions = analyze_melody_functions(melody_pcs, chord_scale_pcs, chord_root_pc) return { "chord_info": chord_info, "degree_in_key": degree, "roman": roman, "mode": mode_name, "tritone_sub": tri_sub, "modal_borrowing": modal_borrow, "note_degrees": note_degrees, "functions": functions, } # -------------------------------------------- # Example usage # -------------------------------------------- if __name__ == "__main__": GLOBAL_KEY = "G" # preconfigured global major key print("Interactive chord/melody analyzer") print("Global key is set to:", GLOBAL_KEY) print("Type lines like: Eb7: D, E, G, B") print('Type "quit" to exit.\n') while True: line = input("Enter [chord]: key1, key2, ... > ").strip() # quit condition if line.lower() == "quit": print("Goodbye.") break # basic validation if ":" not in line: print("Error: input must be in the form [chord]: key1, key2, ...") continue chord_part, melody_part = line.split(":", 1) chord = chord_part.strip() melody_raw = melody_part.strip() if not chord or not melody_raw: print("Error: missing chord or melody notes. Try again.") continue # parse melody notes, allow extra spaces melody_tokens = [tok.strip() for tok in melody_raw.split(",")] melody = [tok for tok in melody_tokens if tok] if not melody: print("Error: no valid note names found after ':'. Try again.") continue try: result = analyze_chord_and_melody(chord, GLOBAL_KEY, melody) except ValueError as e: # catches unknown note names or similar parsing issues print(f"Error while analyzing: {e}") print("Please check your chord and note names and try again.") continue print("\n====================================") print("Chord:", chord) print("In key of", GLOBAL_KEY) print("Degree in key:", result["degree_in_key"]) print("Roman numeral:", result["roman"]) print("Mode:", result["mode"]) if result["tritone_sub"]: print("Note:", result["tritone_sub"]) if result["modal_borrowing"]: print("Note:", result["modal_borrowing"]) print("\nMelody note degrees (per chord):") for note, deg in zip(melody, result["note_degrees"]): print(f" {note:3} -> {deg}") print("\nFunctions per note index:") for i, labels in result["functions"].items(): if labels: print(f" index {i}, note {melody[i]}: {', '.join(labels)}") print("====================================\n")