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:
parent
0dbdfe02cb
commit
16ecb192d1
133
src/analyze.py
Normal file
133
src/analyze.py
Normal 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())
|
||||
24
src/io.py
24
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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue