134 lines
3.9 KiB
Python
134 lines
3.9 KiB
Python
|
|
"""Analyze chord sequence outputs."""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import statistics
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
|
||
|
|
def analyze_chords(
|
||
|
|
chords: list,
|
||
|
|
config: dict | None = None,
|
||
|
|
) -> dict:
|
||
|
|
"""Analyze chord sequence and return metrics.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
chords: List of chords, each chord is a list of pitch dicts
|
||
|
|
config: Optional config with target_octaves, melodic_max, max_path
|
||
|
|
|
||
|
|
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)
|
||
|
|
|
||
|
|
# Basic info
|
||
|
|
num_chords = len(chords)
|
||
|
|
num_voices = len(chords[0]) if chords else 0
|
||
|
|
|
||
|
|
# Melodic violations
|
||
|
|
violations = 0
|
||
|
|
max_violation = 0
|
||
|
|
voice_changes = 0
|
||
|
|
for i in range(1, num_chords):
|
||
|
|
for v in range(num_voices):
|
||
|
|
diff = abs(chords[i][v]["cents"] - chords[i - 1][v]["cents"])
|
||
|
|
if diff > 0:
|
||
|
|
voice_changes += 1
|
||
|
|
if diff > melodic_max:
|
||
|
|
violations += 1
|
||
|
|
max_violation = max(max_violation, diff)
|
||
|
|
|
||
|
|
# Target range
|
||
|
|
target_octaves = config.get("target_range_octaves", 2.0)
|
||
|
|
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
|
||
|
|
|
||
|
|
return {
|
||
|
|
"num_chords": num_chords,
|
||
|
|
"num_voices": num_voices,
|
||
|
|
"melodic_max": melodic_max,
|
||
|
|
"violations": violations,
|
||
|
|
"max_violation": max_violation,
|
||
|
|
"voice_changes": voice_changes,
|
||
|
|
"start_avg_cents": start_avg,
|
||
|
|
"end_avg_cents": end_avg,
|
||
|
|
"target_cents": target_cents,
|
||
|
|
"actual_cents": actual_cents,
|
||
|
|
"target_percent": target_percent,
|
||
|
|
"target_octaves": target_octaves,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def format_analysis(metrics: dict) -> str:
|
||
|
|
"""Format analysis metrics as readable output."""
|
||
|
|
lines = [
|
||
|
|
"=== Analysis ===",
|
||
|
|
f"Path length: {metrics['num_chords']} chords, {metrics['num_voices']} voices",
|
||
|
|
"",
|
||
|
|
"--- Melodic Threshold ---",
|
||
|
|
f"Max allowed: {metrics['melodic_max']} cents",
|
||
|
|
f"Violations: {metrics['violations']}",
|
||
|
|
f"Max violation: {metrics['max_violation']:.0f} cents",
|
||
|
|
f"Voice changes: {metrics['voice_changes']}",
|
||
|
|
"",
|
||
|
|
"--- Target Range ---",
|
||
|
|
f"Target: {metrics['target_octaves']} octaves ({metrics['target_cents']:.0f} cents)",
|
||
|
|
f"Start avg: {metrics['start_avg_cents']:.0f} cents",
|
||
|
|
f"End avg: {metrics['end_avg_cents']:.0f} cents",
|
||
|
|
f"Achieved: {metrics['actual_cents']:.0f} cents ({metrics['target_percent']:.1f}%)",
|
||
|
|
]
|
||
|
|
return "\n".join(lines)
|
||
|
|
|
||
|
|
|
||
|
|
def analyze_file(file_path: str | Path) -> dict:
|
||
|
|
"""Load and analyze a chord file."""
|
||
|
|
with open(file_path) as f:
|
||
|
|
chords = json.load(f)
|
||
|
|
return analyze_chords(chords)
|
||
|
|
|
||
|
|
|
||
|
|
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",
|
||
|
|
)
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
file_path = Path(args.file)
|
||
|
|
if not file_path.exists():
|
||
|
|
print(f"Error: File not found: {file_path}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
metrics = analyze_file(file_path)
|
||
|
|
|
||
|
|
if args.json:
|
||
|
|
print(json.dumps(metrics, indent=2))
|
||
|
|
else:
|
||
|
|
print(format_analysis(metrics))
|
||
|
|
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
exit(main())
|