Files
JazzTheory/ai-gen.py
2025-12-12 16:51:08 -06:00

397 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================
# 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: 17
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 (011) 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 (17) 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")