Create ai-gen.py
This commit is contained in:
396
ai-gen.py
Normal file
396
ai-gen.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user