2026-03-15 10:42:22 +01:00
|
|
|
"""Analyze chord sequence outputs."""
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def analyze_chords(
|
|
|
|
|
chords: list,
|
|
|
|
|
config: dict | None = None,
|
2026-03-15 11:13:24 +01:00
|
|
|
graph_path: list | None = None,
|
2026-03-15 10:42:22 +01:00
|
|
|
) -> dict:
|
|
|
|
|
"""Analyze chord sequence and return metrics.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
chords: List of chords, each chord is a list of pitch dicts
|
2026-03-15 10:48:06 +01:00
|
|
|
config: Optional config with:
|
|
|
|
|
- target_range_octaves: target octaves (default: 2.0)
|
|
|
|
|
- melodic_threshold_max: max cents per voice movement (default: 300)
|
|
|
|
|
- max_path: path length (default: 50)
|
|
|
|
|
- graph_nodes: total nodes in graph (optional, for Hamiltonian coverage)
|
2026-03-15 11:13:24 +01:00
|
|
|
graph_path: Optional list of graph node hashes for Hamiltonian analysis
|
2026-03-15 10:42:22 +01:00
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with analysis metrics
|
|
|
|
|
"""
|
|
|
|
|
if config is None:
|
|
|
|
|
config = {}
|
|
|
|
|
|
|
|
|
|
target_octaves = config.get("target_range_octaves", 2.0)
|
|
|
|
|
melodic_max = config.get("melodic_threshold_max", 300)
|
|
|
|
|
max_path = config.get("max_path", 50)
|
2026-03-15 10:48:06 +01:00
|
|
|
graph_nodes = config.get("graph_nodes", None)
|
2026-03-15 10:42:22 +01:00
|
|
|
|
|
|
|
|
# Basic info
|
|
|
|
|
num_chords = len(chords)
|
|
|
|
|
num_voices = len(chords[0]) if chords else 0
|
2026-03-15 10:48:06 +01:00
|
|
|
num_steps = num_chords - 1 if num_chords > 0 else 0
|
2026-03-15 10:42:22 +01:00
|
|
|
|
2026-03-15 10:48:06 +01:00
|
|
|
# ========== Melodic Threshold ==========
|
|
|
|
|
melodic_violations = 0
|
2026-03-15 10:42:22 +01:00
|
|
|
max_violation = 0
|
2026-03-15 10:48:06 +01:00
|
|
|
total_movement = 0
|
|
|
|
|
max_movement = 0
|
|
|
|
|
|
|
|
|
|
# ========== Contrary Motion ==========
|
|
|
|
|
contrary_motion_steps = 0
|
2026-03-15 11:31:24 +01:00
|
|
|
contrary_motion_score = 0.0
|
2026-03-15 10:48:06 +01:00
|
|
|
|
2026-03-15 11:13:24 +01:00
|
|
|
# ========== DCA (Voice Stay Counts) ==========
|
|
|
|
|
# Track how long each voice stays before changing
|
|
|
|
|
voice_stay_counts = [0] * num_voices # Current stay count per voice
|
|
|
|
|
stay_counts_when_changed = [] # All stay counts recorded when voices changed
|
|
|
|
|
max_voice_stay = 0
|
2026-03-15 10:48:06 +01:00
|
|
|
|
|
|
|
|
# ========== Hamiltonian ==========
|
|
|
|
|
unique_nodes = set()
|
|
|
|
|
node_hashes = []
|
|
|
|
|
|
2026-03-15 10:42:22 +01:00
|
|
|
for i in range(1, num_chords):
|
2026-03-15 10:48:06 +01:00
|
|
|
cent_diffs = []
|
|
|
|
|
voices_changed = 0
|
|
|
|
|
|
2026-03-15 10:42:22 +01:00
|
|
|
for v in range(num_voices):
|
2026-03-15 10:48:06 +01:00
|
|
|
curr_cents = chords[i][v]["cents"]
|
|
|
|
|
prev_cents = chords[i - 1][v]["cents"]
|
|
|
|
|
diff = curr_cents - prev_cents
|
|
|
|
|
cent_diffs.append(diff)
|
|
|
|
|
|
|
|
|
|
# Melodic
|
|
|
|
|
abs_diff = abs(diff)
|
|
|
|
|
total_movement += abs_diff
|
|
|
|
|
max_movement = max(max_movement, abs_diff)
|
|
|
|
|
if abs_diff > melodic_max:
|
|
|
|
|
melodic_violations += 1
|
|
|
|
|
max_violation = max(max_violation, abs_diff)
|
|
|
|
|
|
|
|
|
|
# DCA
|
|
|
|
|
if abs_diff > 0:
|
|
|
|
|
voices_changed += 1
|
|
|
|
|
|
|
|
|
|
# Track unique nodes
|
|
|
|
|
node_hash = tuple(
|
|
|
|
|
tuple(p["hs_array"]) for p in chords[i]
|
|
|
|
|
) # Convert lists to tuples for hashing
|
|
|
|
|
unique_nodes.add(node_hash)
|
|
|
|
|
node_hashes.append(node_hash)
|
|
|
|
|
|
2026-03-15 11:31:24 +01:00
|
|
|
# Contrary motion: weighted by closeness to half split
|
|
|
|
|
# Count moving voices (cent_diff != 0)
|
|
|
|
|
num_up = sum(1 for d in cent_diffs if d > 0)
|
|
|
|
|
num_down = sum(1 for d in cent_diffs if d < 0)
|
|
|
|
|
num_moving = num_up + num_down
|
|
|
|
|
|
|
|
|
|
if num_moving >= 2:
|
|
|
|
|
ideal_up = num_moving / 2
|
|
|
|
|
distance = abs(num_up - ideal_up)
|
|
|
|
|
# Factor = 1.0 - (distance / ideal_up)
|
|
|
|
|
# Returns 0.0 if all move same direction, 1.0 if exact half split
|
|
|
|
|
contrary_factor = max(0.0, 1.0 - (distance / ideal_up))
|
|
|
|
|
contrary_motion_score += contrary_factor
|
|
|
|
|
if contrary_factor > 0:
|
2026-03-15 10:48:06 +01:00
|
|
|
contrary_motion_steps += 1
|
|
|
|
|
|
2026-03-15 11:13:24 +01:00
|
|
|
# DCA: Track stay counts per voice
|
|
|
|
|
for v in range(num_voices):
|
|
|
|
|
curr_cents = chords[i][v]["cents"]
|
|
|
|
|
prev_cents = chords[i - 1][v]["cents"]
|
|
|
|
|
if curr_cents != prev_cents:
|
|
|
|
|
# Voice changed - record how long it stayed
|
|
|
|
|
stay_counts_when_changed.append(voice_stay_counts[v])
|
|
|
|
|
max_voice_stay = max(max_voice_stay, voice_stay_counts[v])
|
|
|
|
|
voice_stay_counts[v] = 0 # Reset stay count
|
|
|
|
|
else:
|
|
|
|
|
voice_stay_counts[v] += 1 # Increment stay count
|
2026-03-15 10:48:06 +01:00
|
|
|
|
|
|
|
|
# ========== Target Range ==========
|
2026-03-15 10:42:22 +01:00
|
|
|
target_cents = target_octaves * 1200
|
|
|
|
|
|
|
|
|
|
if chords:
|
|
|
|
|
start_avg = sum(p["cents"] for p in chords[0]) / len(chords[0])
|
|
|
|
|
end_avg = sum(p["cents"] for p in chords[-1]) / len(chords[-1])
|
|
|
|
|
actual_cents = end_avg - start_avg
|
|
|
|
|
target_percent = (actual_cents / target_cents) * 100 if target_cents > 0 else 0
|
|
|
|
|
else:
|
|
|
|
|
start_avg = end_avg = actual_cents = target_percent = 0
|
|
|
|
|
|
2026-03-15 10:48:06 +01:00
|
|
|
# ========== DCA Summary ==========
|
2026-03-15 11:13:24 +01:00
|
|
|
avg_voice_stay = (
|
|
|
|
|
sum(stay_counts_when_changed) / len(stay_counts_when_changed)
|
|
|
|
|
if stay_counts_when_changed
|
2026-03-15 10:48:06 +01:00
|
|
|
else 0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ========== Hamiltonian Coverage ==========
|
2026-03-15 11:13:24 +01:00
|
|
|
# Use graph_path if provided (accurate), otherwise hash output chords (may differ due to transposition)
|
|
|
|
|
if graph_path:
|
|
|
|
|
hamiltonian_unique_nodes = len(set(graph_path))
|
|
|
|
|
else:
|
|
|
|
|
hamiltonian_unique_nodes = len(unique_nodes)
|
|
|
|
|
|
2026-03-15 10:48:06 +01:00
|
|
|
hamiltonian_coverage = (
|
2026-03-15 11:13:24 +01:00
|
|
|
(hamiltonian_unique_nodes / graph_nodes * 100) if graph_nodes else None
|
2026-03-15 10:48:06 +01:00
|
|
|
)
|
|
|
|
|
|
2026-03-15 10:42:22 +01:00
|
|
|
return {
|
|
|
|
|
"num_chords": num_chords,
|
|
|
|
|
"num_voices": num_voices,
|
2026-03-15 10:48:06 +01:00
|
|
|
"num_steps": num_steps,
|
|
|
|
|
# Melodic
|
2026-03-15 10:42:22 +01:00
|
|
|
"melodic_max": melodic_max,
|
2026-03-15 10:48:06 +01:00
|
|
|
"melodic_violations": melodic_violations,
|
|
|
|
|
"melodic_max_violation": max_violation,
|
|
|
|
|
"melodic_avg_movement": total_movement / num_steps if num_steps > 0 else 0,
|
|
|
|
|
"melodic_max_movement": max_movement,
|
|
|
|
|
# Contrary Motion
|
|
|
|
|
"contrary_motion_steps": contrary_motion_steps,
|
|
|
|
|
"contrary_motion_percent": (
|
|
|
|
|
(contrary_motion_steps / num_steps * 100) if num_steps > 0 else 0
|
|
|
|
|
),
|
2026-03-15 11:31:24 +01:00
|
|
|
"contrary_motion_score": contrary_motion_score / num_steps
|
|
|
|
|
if num_steps > 0
|
|
|
|
|
else 0,
|
2026-03-15 10:48:06 +01:00
|
|
|
# DCA
|
2026-03-15 11:13:24 +01:00
|
|
|
"dca_avg_voice_stay": avg_voice_stay,
|
|
|
|
|
"dca_max_voice_stay": max_voice_stay,
|
2026-03-15 10:48:06 +01:00
|
|
|
# Hamiltonian
|
2026-03-15 11:13:24 +01:00
|
|
|
"hamiltonian_unique_nodes": hamiltonian_unique_nodes,
|
2026-03-15 10:48:06 +01:00
|
|
|
"hamiltonian_coverage": hamiltonian_coverage,
|
|
|
|
|
# Target Range
|
|
|
|
|
"target_octaves": target_octaves,
|
2026-03-15 10:42:22 +01:00
|
|
|
"target_cents": target_cents,
|
2026-03-15 10:48:06 +01:00
|
|
|
"target_start_cents": start_avg,
|
|
|
|
|
"target_end_cents": end_avg,
|
|
|
|
|
"target_actual_cents": actual_cents,
|
2026-03-15 10:42:22 +01:00
|
|
|
"target_percent": target_percent,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_analysis(metrics: dict) -> str:
|
|
|
|
|
"""Format analysis metrics as readable output."""
|
|
|
|
|
lines = [
|
|
|
|
|
"=== Analysis ===",
|
2026-03-15 10:48:06 +01:00
|
|
|
f"Path: {metrics['num_chords']} chords, {metrics['num_steps']} steps, {metrics['num_voices']} voices",
|
2026-03-15 10:42:22 +01:00
|
|
|
"",
|
|
|
|
|
"--- Melodic Threshold ---",
|
|
|
|
|
f"Max allowed: {metrics['melodic_max']} cents",
|
2026-03-15 10:48:06 +01:00
|
|
|
f"Violations: {metrics['melodic_violations']}",
|
|
|
|
|
f"Max violation: {metrics['melodic_max_violation']:.0f} cents",
|
|
|
|
|
f"Avg movement: {metrics['melodic_avg_movement']:.1f} cents",
|
|
|
|
|
f"Max movement: {metrics['melodic_max_movement']:.0f} cents",
|
|
|
|
|
"",
|
|
|
|
|
"--- Contrary Motion ---",
|
|
|
|
|
f"Steps with contrary: {metrics['contrary_motion_steps']}",
|
|
|
|
|
f"Percentage: {metrics['contrary_motion_percent']:.1f}%",
|
2026-03-15 11:31:24 +01:00
|
|
|
f"Avg score: {metrics['contrary_motion_score']:.2f}",
|
2026-03-15 10:48:06 +01:00
|
|
|
"",
|
2026-03-15 11:13:24 +01:00
|
|
|
"--- DCA (Voice Stay) ---",
|
|
|
|
|
f"Avg stay count: {metrics['dca_avg_voice_stay']:.2f} steps",
|
|
|
|
|
f"Max stay count: {metrics['dca_max_voice_stay']} steps",
|
2026-03-15 10:42:22 +01:00
|
|
|
"",
|
2026-03-15 10:48:06 +01:00
|
|
|
"--- Hamiltonian ---",
|
|
|
|
|
f"Unique nodes: {metrics['hamiltonian_unique_nodes']}",
|
2026-03-15 10:42:22 +01:00
|
|
|
]
|
2026-03-15 10:48:06 +01:00
|
|
|
|
|
|
|
|
if metrics["hamiltonian_coverage"] is not None:
|
|
|
|
|
lines.append(f"Coverage: {metrics['hamiltonian_coverage']:.1f}%")
|
|
|
|
|
|
|
|
|
|
lines.extend(
|
|
|
|
|
[
|
|
|
|
|
"",
|
|
|
|
|
"--- Target Range ---",
|
|
|
|
|
f"Target: {metrics['target_octaves']} octaves ({metrics['target_cents']:.0f} cents)",
|
|
|
|
|
f"Start: {metrics['target_start_cents']:.0f} cents",
|
|
|
|
|
f"End: {metrics['target_end_cents']:.0f} cents",
|
|
|
|
|
f"Achieved: {metrics['target_actual_cents']:.0f} cents ({metrics['target_percent']:.1f}%)",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-15 10:42:22 +01:00
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 10:48:06 +01:00
|
|
|
def analyze_file(file_path: str | Path, config: dict | None = None) -> dict:
|
2026-03-15 10:42:22 +01:00
|
|
|
"""Load and analyze a chord file."""
|
2026-03-15 11:13:24 +01:00
|
|
|
file_path = Path(file_path)
|
2026-03-15 10:42:22 +01:00
|
|
|
with open(file_path) as f:
|
|
|
|
|
chords = json.load(f)
|
2026-03-15 11:13:24 +01:00
|
|
|
|
|
|
|
|
# Try to load graph_path if it exists
|
|
|
|
|
graph_path = None
|
|
|
|
|
graph_path_file = file_path.parent / "graph_path.json"
|
|
|
|
|
if graph_path_file.exists():
|
|
|
|
|
with open(graph_path_file) as f:
|
|
|
|
|
graph_path = json.load(f)
|
|
|
|
|
|
|
|
|
|
return analyze_chords(chords, config, graph_path)
|
2026-03-15 10:42:22 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
parser = argparse.ArgumentParser(description="Analyze chord sequence outputs")
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"file",
|
|
|
|
|
nargs="?",
|
|
|
|
|
default="output/output_chords.json",
|
|
|
|
|
help="Path to chord JSON file (default: output/output_chords.json)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--json",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Output raw JSON instead of formatted text",
|
|
|
|
|
)
|
2026-03-15 10:48:06 +01:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--target-range",
|
|
|
|
|
type=float,
|
|
|
|
|
default=2.0,
|
|
|
|
|
help="Target range in octaves (default: 2.0)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--melodic-max",
|
|
|
|
|
type=int,
|
|
|
|
|
default=300,
|
|
|
|
|
help="Max melodic threshold in cents (default: 300)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--max-path",
|
|
|
|
|
type=int,
|
|
|
|
|
default=50,
|
|
|
|
|
help="Max path length (default: 50)",
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--graph-nodes",
|
|
|
|
|
type=int,
|
|
|
|
|
default=None,
|
|
|
|
|
help="Total nodes in graph (for Hamiltonian coverage)",
|
|
|
|
|
)
|
2026-03-15 10:42:22 +01:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
file_path = Path(args.file)
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
print(f"Error: File not found: {file_path}")
|
|
|
|
|
return 1
|
|
|
|
|
|
2026-03-15 10:48:06 +01:00
|
|
|
config = {
|
|
|
|
|
"target_range_octaves": args.target_range,
|
|
|
|
|
"melodic_threshold_max": args.melodic_max,
|
|
|
|
|
"max_path": args.max_path,
|
|
|
|
|
"graph_nodes": args.graph_nodes,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metrics = analyze_file(file_path, config)
|
2026-03-15 10:42:22 +01:00
|
|
|
|
|
|
|
|
if args.json:
|
|
|
|
|
print(json.dumps(metrics, indent=2))
|
|
|
|
|
else:
|
|
|
|
|
print(format_analysis(metrics))
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
exit(main())
|