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
This commit is contained in:
Michael Winter 2026-03-15 10:42:22 +01:00
parent 0dbdfe02cb
commit 16ecb192d1
2 changed files with 157 additions and 0 deletions

133
src/analyze.py Normal file
View file

@ -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())

View file

@ -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()