From 16ecb192d1faae08aa71cd99d94d0e91df6764d6 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Sun, 15 Mar 2026 10:42:22 +0100 Subject: [PATCH] Add analysis tool for chord sequences - Add src/analyze.py: standalone analysis script - Add --stats CLI flag to show stats after generation - Analyze: melodic violations, target range %, voice changes --- src/analyze.py | 133 +++++++++++++++++++++++++++++++++++++++++++++++++ src/io.py | 24 +++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/analyze.py diff --git a/src/analyze.py b/src/analyze.py new file mode 100644 index 0000000..4a4b29f --- /dev/null +++ b/src/analyze.py @@ -0,0 +1,133 @@ +"""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()) diff --git a/src/io.py b/src/io.py index 3b9e1dd..8c4fc44 100644 --- a/src/io.py +++ b/src/io.py @@ -350,6 +350,11 @@ def main(): default="output", help="Output directory for generated files", ) + parser.add_argument( + "--stats", + action="store_true", + help="Show analysis statistics after generation", + ) args = parser.parse_args() # Select dims @@ -466,6 +471,25 @@ def main(): ) print(f"Written to {args.output_dir}/output_frequencies.txt") + # Show stats if requested + if args.stats: + from .analyze import analyze_chords, format_analysis + + config = { + "melodic_threshold_max": args.melodic_max, + "target_range_octaves": args.target_range, + "max_path": args.max_path, + } + # Load the chords from the output file + import json + + chords_file = os.path.join(args.output_dir, "output_chords.json") + with open(chords_file) as f: + chords = json.load(f) + metrics = analyze_chords(chords, config) + print() + print(format_analysis(metrics)) + if __name__ == "__main__": main()