Files
JazzTheory/ai-gen_v2.py
2025-12-13 00:48:07 -06:00

563 lines
19 KiB
Python

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()