From 778ccb29be64a7cf38f901014d2f36e2519292a9 Mon Sep 17 00:00:00 2001 From: Zheyuan Wu <60459821+Trance-0@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:48:07 -0600 Subject: [PATCH] Create ai-gen_v2.py --- ai-gen_v2.py | 562 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 562 insertions(+) create mode 100644 ai-gen_v2.py diff --git a/ai-gen_v2.py b/ai-gen_v2.py new file mode 100644 index 0000000..db24e90 --- /dev/null +++ b/ai-gen_v2.py @@ -0,0 +1,562 @@ +from typing import List, Dict, Any, Tuple + +# ============================================================ +# CONFIG / CONSTANTS (keep most variables here) +# ============================================================ + +GLOBAL_KEY = "G" # global major key center +DEBUG = 0 # 0..5 (0 = no debug, 5 = max) + +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, +} + +MAJOR_INTERVALS = [0, 2, 4, 5, 7, 9, 11] # 7-note set + +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", +} + +ROMANS = {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", 7: "VII"} + +# 21-mode catalog: each is a 7-note set described by intervals from its root. +# Ordering matters for tie-break: Major-family first, then Melodic-minor, then Harmonic-minor. + +MODE_CATALOG: List[Dict[str, Any]] = [] + +def _build_catalog(): + # Major family (7) + major_modes = [ + ("Ionian (major)", [0, 2, 4, 5, 7, 9, 11]), + ("Dorian", [0, 2, 3, 5, 7, 9, 10]), + ("Phrygian", [0, 1, 3, 5, 7, 8, 10]), + ("Lydian", [0, 2, 4, 6, 7, 9, 11]), + ("Mixolydian", [0, 2, 4, 5, 7, 9, 10]), + ("Aeolian (natural minor)", [0, 2, 3, 5, 7, 8, 10]), + ("Locrian", [0, 1, 3, 5, 6, 8, 10]), + ] + + # Melodic minor family (7) (ascending melodic minor) + melodic_minor_modes = [ + ("Melodic minor", [0, 2, 3, 5, 7, 9, 11]), + ("Dorian b2", [0, 1, 3, 5, 7, 9, 10]), + ("Lydian augmented", [0, 2, 4, 6, 8, 9, 11]), + ("Lydian dominant", [0, 2, 4, 6, 7, 9, 10]), + ("Mixolydian b6", [0, 2, 4, 5, 7, 8, 10]), + ("Locrian #2", [0, 2, 3, 5, 6, 8, 10]), + ("Altered (super locrian)", [0, 1, 3, 4, 6, 8, 10]), + ] + + # Harmonic minor family (7) + harmonic_minor_modes = [ + ("Harmonic minor", [0, 2, 3, 5, 7, 8, 11]), + ("Locrian #6", [0, 1, 3, 5, 6, 9, 10]), + ("Ionian #5", [0, 2, 4, 5, 8, 9, 11]), + ("Dorian #4", [0, 2, 3, 6, 7, 9, 10]), + ("Phrygian dominant", [0, 1, 4, 5, 7, 8, 10]), + ("Lydian #2", [0, 3, 4, 6, 7, 9, 11]), + ("Altered diminished", [0, 1, 3, 4, 6, 7, 9]), # sometimes called "Ultralocrian" + ] + + # store + for fam_i, (family, modes) in enumerate([ + ("major", major_modes), + ("melodic_minor", melodic_minor_modes), + ("harmonic_minor", harmonic_minor_modes), + ]): + for mode_i, (name, intervals) in enumerate(modes): + MODE_CATALOG.append({ + "family": family, + "family_rank": fam_i, # tie-break: smaller is preferred + "mode_rank": mode_i, # within family + "name": name, + "intervals": intervals + }) + +_build_catalog() + +# ============================================================ +# BASIC HELPERS +# ============================================================ + +def dprint(level: int, msg: str) -> None: + if DEBUG >= level: + print(msg) + +def normalize_note(name: str) -> str: + s = name.strip().replace("♭", "b").replace("♯", "#") + if not s: + return s + return s[0].upper() + s[1:] + +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] + +def build_scale_from_intervals(root_pc: int, intervals: List[int]) -> List[int]: + return [(root_pc + i) % 12 for i in intervals] + +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 pitch_to_degree_label(note_pc: int, root_pc: int) -> str: + return DEGREE_LABELS[(note_pc - root_pc) % 12] + +# ============================================================ +# CHORD PARSING + REQUIRED TONES +# ============================================================ + +def parse_chord_symbol(symbol: str) -> Dict[str, Any]: + """ + Parses roots like D, Db, F# + Parses qualities: maj7 / M7, m / m7, dominant by presence of 7/9/13 without maj/min marker. + Parses alterations: b9, #9, b13, #11, b5, #5 + Also recognizes plain extensions: 9, 11, 13 (as unaltered). + """ + s = symbol.strip().replace("♭", "b").replace("♯", "#") + if not s: + raise ValueError("Empty chord symbol.") + + # root letter + optional accidental + if len(s) >= 2 and s[1] in ("b", "#"): + root, rest = s[:2], s[2:] + else: + root, rest = s[0], s[1:] + + root = normalize_note(root) + r = rest.lower() + + # Quality + quality = "maj" + if "maj" in r or "ma7" in r or "maj7" in r or "∆" in r: + quality = "maj" + elif r.startswith("m") or "min" in r: + quality = "min" + + # Dominant (7/9/13) when not maj/min explicitly + dominant = False + if any(x in r for x in ["7", "9", "11", "13"]) and ("maj" not in r and not (r.startswith("m") or "min" in r)): + quality = "dom" + dominant = True + + # Extract alterations and extensions + # We treat "b9" etc as explicit, and "9"/"11"/"13" as plain extensions if present. + alts = [] + for a in ["b9", "#9", "b13", "#11", "b5", "#5"]: + if a in r: + alts.append(a) + + exts = [] + for e in ["13", "11", "9", "7"]: # check longer first + if e in r: + exts.append(e) + + return { + "symbol": symbol, + "root": root, + "quality": quality, # 'maj', 'min', 'dom' + "dominant": dominant, + "alts": alts, + "exts": exts, + } + +def chord_required_semitones(ch: Dict[str, Any]) -> Tuple[List[int], List[int]]: + """ + Returns (core_tones, extra_tones) as semitone offsets from chord root. + core_tones: identity tones (triad + 7 if present/assumed by quality) + extra_tones: extensions/alterations (9/11/13, b9, #9, #11, b13, etc) + """ + q = ch["quality"] + exts = ch["exts"] + alts = set(ch["alts"]) + + # core: 1 + 3/b3 + 5 + 7/b7 depending on chord quality + if q == "maj": + core = [0, 4, 7] + ([11] if ("7" in exts or "maj7" in ch["symbol"].lower() or "ma7" in ch["symbol"].lower()) else []) + elif q == "min": + core = [0, 3, 7] + ([10] if ("7" in exts or "m7" in ch["symbol"].lower()) else []) + else: # dom + core = [0, 4, 7, 10] # dominant implies b7 + + extra: List[int] = [] + + # plain extensions (if written) + if "9" in exts: + extra.append(2) + if "11" in exts: + extra.append(5) + if "13" in exts: + extra.append(9) + + # alterations override / add + if "b9" in alts: + extra.append(1) + if "#9" in alts: + extra.append(3) + if "#11" in alts: + extra.append(6) + if "b13" in alts: + extra.append(8) + if "b5" in alts: + extra.append(6) + if "#5" in alts: + extra.append(8) + + # remove duplicates while preserving order + def uniq(xs: List[int]) -> List[int]: + out = [] + seen = set() + for x in xs: + if x not in seen: + out.append(x) + seen.add(x) + return out + + return uniq(core), uniq(extra) + +# ============================================================ +# ROMAN NUMERAL IN GLOBAL MAJOR KEY +# ============================================================ + +def degree_in_global_major(chord_root: str, global_key: str) -> int: + scale = build_major_scale_pcs(global_key) + r_pc = note_to_pc(chord_root) + return scale.index(r_pc) + 1 if r_pc in scale else 0 + +def roman_for_degree(deg: int, quality: str) -> str: + if deg == 0: + return "N/A" + base = ROMANS[deg] + return base.lower() if quality == "min" else base + +# ============================================================ +# 21-MODE MATCHING ENGINE +# ============================================================ + +def mode_match_rank( + root_pc: int, + core: List[int], + extra: List[int], + melody_pcs: List[int], + mode: Dict[str, Any], +) -> Dict[str, Any]: + """ + Scoring idea: + - core tones should strongly be present in mode (big penalty if missing) + - extra (extensions/alterations) should be present if possible (smaller penalty) + - melody notes in mode add points, weighted by order (earlier notes count slightly more) + """ + mode_pcs = set(build_scale_from_intervals(root_pc, mode["intervals"])) + + core_pcs = {(root_pc + s) % 12 for s in core} + extra_pcs = {(root_pc + s) % 12 for s in extra} + + missing_core = [pc for pc in core_pcs if pc not in mode_pcs] + missing_extra = [pc for pc in extra_pcs if pc not in mode_pcs] + + # melody match with order weighting + n = max(len(melody_pcs), 1) + weights = [(n - i) / n for i in range(n)] # first notes slightly heavier + melody_hits = [] + melody_score = 0.0 + for i, pc in enumerate(melody_pcs): + hit = (pc in mode_pcs) + melody_hits.append(hit) + if hit: + melody_score += 1.0 * weights[i] + + # penalties / bonuses + score = 0.0 + score += 4.0 * (len(core_pcs) - len(missing_core)) + score -= 10.0 * len(missing_core) + score += 1.5 * (len(extra_pcs) - len(missing_extra)) + score -= 2.5 * len(missing_extra) + score += melody_score + + return { + "mode": mode, + "score": score, + "missing_core": missing_core, + "missing_extra": missing_extra, + "melody_score": melody_score, + "melody_hits": melody_hits, + } + +def choose_modes_21( + chord_info: Dict[str, Any], + melody_pcs: List[int], +) -> Dict[str, Any]: + root_pc = note_to_pc(chord_info["root"]) + core, extra = chord_required_semitones(chord_info) + + dprint(4, f"[debug] chord root pc={root_pc}, core semitones={core}, extra semitones={extra}") + + ranked = [] + for mode in MODE_CATALOG: + r = mode_match_rank(root_pc, core, extra, melody_pcs, mode) + ranked.append(r) + + # Sort: + # 1) higher score + # 2) prefer major-family first (family_rank) + # 3) prefer earlier mode_rank + ranked.sort(key=lambda x: (-x["score"], x["mode"]["family_rank"], x["mode"]["mode_rank"])) + + if DEBUG >= 5: + for k in range(min(10, len(ranked))): + m = ranked[k] + dprint(5, f"[debug] rank {k+1}: {m['mode']['family']}::{m['mode']['name']} score={m['score']:.3f} " + f"missing_core={len(m['missing_core'])} missing_extra={len(m['missing_extra'])} " + f"melody_score={m['melody_score']:.3f}") + + best = ranked[0] + # include some alternatives that are close + alternatives = ranked[1:6] + + return { + "best": best, + "alternatives": alternatives, + } + +# ============================================================ +# SIMPLE MELODY FUNCTION TAGS (per chord) +# ============================================================ + +def analyze_melody_functions(melody_pcs: List[int], root_pc: int, quality: str) -> Dict[int, List[str]]: + """ + Heuristics, minimal and local: + - chord_tone: chord triad + 7 (based on quality) + - guide_tone: the 3rd and 7th relative to root (major 3rd + maj7 assumed for maj7-ish, + minor 3rd + b7 for minor7-ish, major3 + b7 for dominant) + - scale_line: 3 stepwise notes + - neighbor: a == c and b step away + - passing / chromatic passing: between two chord tones + """ + n = len(melody_pcs) + labels: Dict[int, List[str]] = {i: [] for i in range(n)} + + if quality == "maj": + chord_tones = {(root_pc + s) % 12 for s in [0, 4, 7, 11]} + guide_tones = {(root_pc + 4) % 12, (root_pc + 11) % 12} + elif quality == "min": + chord_tones = {(root_pc + s) % 12 for s in [0, 3, 7, 10]} + guide_tones = {(root_pc + 3) % 12, (root_pc + 10) % 12} + else: + chord_tones = {(root_pc + s) % 12 for s in [0, 4, 7, 10]} + guide_tones = {(root_pc + 4) % 12, (root_pc + 10) % 12} + + for i, pc in enumerate(melody_pcs): + if pc in guide_tones: + labels[i].append("guide_tone") + if pc in chord_tones: + labels[i].append("chord_tone") + + # scale line + 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: + labels[i - 1].append("scale_line") + labels[i].append("scale_line") + labels[i + 1].append("scale_line") + + # neighbors + 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): + labels[i].append("upper_neighbor") + elif step in (10, 11): + labels[i].append("lower_neighbor") + + # passing / chromatic passing (no scale context; purely chord-tone bridge) + for i in range(1, n - 1): + a, b, c = melody_pcs[i - 1], melody_pcs[i], melody_pcs[i + 1] + 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 is diatonic to either end? we don't know scale here, so treat semitone as chromatic + if up == 1 or down == 1 or up == 11 or down == 11: + labels[i].append("chromatic_passing_tone") + else: + labels[i].append("passing_tone") + + return labels + +# ============================================================ +# MAIN ANALYSIS +# ============================================================ + +def analyze_chord_and_melody(chord_symbol: str, melody_notes: List[str], global_key: str) -> Dict[str, Any]: + chord = parse_chord_symbol(chord_symbol) + melody_pcs = [note_to_pc(n) for n in melody_notes] + + deg = degree_in_global_major(chord["root"], global_key) + roman = roman_for_degree(deg, chord["quality"]) + + dprint(3, f"[debug] parsed chord={chord} deg={deg} roman={roman}") + + mode_choice = choose_modes_21(chord, melody_pcs) + best = mode_choice["best"] + best_mode = best["mode"] + + root_pc = note_to_pc(chord["root"]) + melody_degrees = [pitch_to_degree_label(pc, root_pc) for pc in melody_pcs] + + functions = analyze_melody_functions(melody_pcs, root_pc, chord["quality"]) + + return { + "global_key": global_key, + "chord": chord, + "degree_in_key": deg, + "roman": roman, + "best_mode": { + "family": best_mode["family"], + "name": best_mode["name"], + "score": best["score"], + "melody_score": best["melody_score"], + "missing_core": best["missing_core"], + "missing_extra": best["missing_extra"], + }, + "alternatives": [ + { + "family": a["mode"]["family"], + "name": a["mode"]["name"], + "score": a["score"], + "melody_score": a["melody_score"], + "missing_core": a["missing_core"], + "missing_extra": a["missing_extra"], + } + for a in mode_choice["alternatives"] + ], + "melody_notes": [normalize_note(n) for n in melody_notes], + "melody_degrees": melody_degrees, + "functions": functions, + } + +# ============================================================ +# CLI +# ============================================================ + +def parse_cli_line(line: str) -> Tuple[str, List[str]]: + """ + Expects: [chord]: note1, note2, ... + Allows commas or whitespace between notes. + """ + if ":" not in line: + raise ValueError("Input must contain ':' like D7b9b13: F#, A, C, Eb, Bb") + + chord_part, melody_part = line.split(":", 1) + chord = chord_part.strip() + if not chord: + raise ValueError("Missing chord before ':'.") + + melody_raw = melody_part.strip() + if not melody_raw: + raise ValueError("Missing melody notes after ':'.") + + if "," in melody_raw: + tokens = [t.strip() for t in melody_raw.split(",")] + else: + tokens = [t.strip() for t in melody_raw.split()] + + notes = [t for t in tokens if t] + if not notes: + raise ValueError("No valid note names parsed.") + + return chord, notes + +def main(): + global DEBUG + + print("Interactive chord/melody analyzer (21-mode matching)") + print(f"Global key: {GLOBAL_KEY} major") + print(f"Debug level: {DEBUG} (0..5)") + print("Input format: [chord]: note1, note2, note3 ...") + print('Commands: "quit", or "debug=N" to set debug level.\n') + + while True: + s = input("Enter > ").strip() + if not s: + continue + if s.lower() == "quit": + print("Goodbye.") + break + if s.lower().startswith("debug="): + try: + DEBUG = int(s.split("=", 1)[1].strip()) + if DEBUG < 0 or DEBUG > 5: + raise ValueError + print(f"Debug set to {DEBUG}.") + except ValueError: + print("Error: debug must be an integer 0..5.") + continue + + try: + chord, notes = parse_cli_line(s) + result = analyze_chord_and_melody(chord, notes, GLOBAL_KEY) + except ValueError as e: + print(f"Error: {e}") + print("Please type again.") + continue + + print("\n====================================") + print(f"Chord: {result['chord']['symbol']} (root {result['chord']['root']}, quality {result['chord']['quality']})") + print(f"Global key: {result['global_key']} major") + print(f"Roman numeral (by root in global key): {result['roman']} (degree {result['degree_in_key']})") + + bm = result["best_mode"] + print("\nBest mode match (rooted on chord root):") + print(f" {bm['family']} :: {bm['name']}") + print(f" score={bm['score']:.3f} (melody_score={bm['melody_score']:.3f})") + if bm["missing_core"]: + print(f" WARNING missing core chord tones (pcs): {bm['missing_core']}") + if bm["missing_extra"]: + print(f" missing extension/alteration tones (pcs): {bm['missing_extra']}") + + print("\nAlternatives:") + for a in result["alternatives"]: + print(f" - {a['family']} :: {a['name']} score={a['score']:.3f} melody_score={a['melody_score']:.3f}" + f" missing_core={len(a['missing_core'])} missing_extra={len(a['missing_extra'])}") + + print("\nMelody numeral labels (relative to chord root):") + for i, (n, deg) in enumerate(zip(result["melody_notes"], result["melody_degrees"])): + print(f" [{i}] {n:3} -> {deg}") + + print("\nPer-note function tags:") + for i, labs in result["functions"].items(): + if labs: + print(f" index {i}, note {result['melody_notes'][i]}: {', '.join(sorted(set(labs)))}") + + print("====================================\n") + +if __name__ == "__main__": + main()