diff --git a/ai-gen.py b/ai-gen.py new file mode 100644 index 0000000..2469c7a --- /dev/null +++ b/ai-gen.py @@ -0,0 +1,396 @@ +# ============================================ +# 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")