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",
|
default="output",
|
||||||
help="Output directory for generated files",
|
help="Output directory for generated files",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--stats",
|
||||||
|
action="store_true",
|
||||||
|
help="Show analysis statistics after generation",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Select dims
|
# Select dims
|
||||||
|
|
@ -466,6 +471,25 @@ def main():
|
||||||
)
|
)
|
||||||
print(f"Written to {args.output_dir}/output_frequencies.txt")
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue