feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
321
.agent/skills/suno-lyric-transformer/scripts/analyze-input.py
Normal file
321
.agent/skills/suno-lyric-transformer/scripts/analyze-input.py
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Pre-analyze raw input text to extract deterministic metrics before LLM processing.
|
||||
|
||||
Detects existing structure, counts lines/words/characters, finds repeated phrases,
|
||||
identifies potential rhyme pairs, and estimates needed structure.
|
||||
|
||||
Usage:
|
||||
python analyze-input.py <text-file> [options]
|
||||
|
||||
# Analyze input from a file
|
||||
python analyze-input.py input.txt
|
||||
|
||||
# Analyze from text argument
|
||||
python analyze-input.py --text "Some raw lyrics text"
|
||||
|
||||
# Output to file
|
||||
python analyze-input.py input.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import SUNO_LYRICS_HARD_LIMIT, SUNO_LYRICS_QUALITY_BUDGET
|
||||
|
||||
SCRIPT_NAME = "analyze-input"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
def find_metatags(text: str) -> list[str]:
|
||||
"""Find all metatag-style brackets in text."""
|
||||
return re.findall(r'\[([^\]]+)\]', text)
|
||||
|
||||
|
||||
def find_repeated_phrases(text: str, min_words: int = 3, min_count: int = 2) -> list[dict]:
|
||||
"""Find exact phrase matches of min_words+ words appearing min_count+ times."""
|
||||
lines = text.split('\n')
|
||||
# Collect all non-empty, non-tag lines
|
||||
content_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not re.match(r'^\[.*\]$', stripped):
|
||||
content_lines.append(stripped)
|
||||
|
||||
# Build n-grams from all content
|
||||
all_words = []
|
||||
for line in content_lines:
|
||||
words = re.findall(r"[a-zA-Z']+", line.lower())
|
||||
all_words.extend(words)
|
||||
|
||||
phrases = Counter()
|
||||
for n in range(min_words, min(8, len(all_words) + 1)):
|
||||
for i in range(len(all_words) - n + 1):
|
||||
phrase = " ".join(all_words[i:i + n])
|
||||
phrases[phrase] += 1
|
||||
|
||||
# Filter and deduplicate (remove sub-phrases if a longer phrase has same count)
|
||||
results = {}
|
||||
for phrase, count in phrases.items():
|
||||
if count >= min_count:
|
||||
results[phrase] = count
|
||||
|
||||
# Remove sub-phrases where a longer phrase has the same count
|
||||
filtered = {}
|
||||
sorted_phrases = sorted(results.keys(), key=len, reverse=True)
|
||||
for phrase in sorted_phrases:
|
||||
count = results[phrase]
|
||||
# Check if this is a sub-phrase of an already-kept longer phrase with same count
|
||||
is_sub = False
|
||||
for kept in filtered:
|
||||
if phrase in kept and filtered[kept] == count:
|
||||
is_sub = True
|
||||
break
|
||||
if not is_sub:
|
||||
filtered[phrase] = count
|
||||
|
||||
return [{"phrase": p, "count": c} for p, c in sorted(filtered.items(), key=lambda x: -x[1])]
|
||||
|
||||
|
||||
def find_rhyme_pairs(text: str) -> list[dict]:
|
||||
"""Find potential rhyme pairs based on ending sounds (last 2-3 chars)."""
|
||||
lines = text.split('\n')
|
||||
content_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not re.match(r'^\[.*\]$', stripped):
|
||||
content_lines.append(stripped)
|
||||
|
||||
# Extract last word of each line
|
||||
line_endings = []
|
||||
for i, line in enumerate(content_lines):
|
||||
words = re.findall(r"[a-zA-Z']+", line)
|
||||
if words:
|
||||
line_endings.append((i, words[-1].lower()))
|
||||
|
||||
pairs = []
|
||||
seen = set()
|
||||
|
||||
for idx in range(len(line_endings)):
|
||||
# Check consecutive and alternating lines
|
||||
for offset in (1, 2):
|
||||
if idx + offset < len(line_endings):
|
||||
i, word_a = line_endings[idx]
|
||||
j, word_b = line_endings[idx + offset]
|
||||
|
||||
if word_a == word_b:
|
||||
continue
|
||||
|
||||
# Check if last 2 or 3 characters match
|
||||
match_len = 0
|
||||
if len(word_a) >= 2 and len(word_b) >= 2 and word_a[-2:] == word_b[-2:]:
|
||||
match_len = 2
|
||||
if len(word_a) >= 3 and len(word_b) >= 3 and word_a[-3:] == word_b[-3:]:
|
||||
match_len = 3
|
||||
|
||||
if match_len > 0:
|
||||
pair_key = tuple(sorted([word_a, word_b]))
|
||||
if pair_key not in seen:
|
||||
seen.add(pair_key)
|
||||
pairs.append({
|
||||
"words": [word_a, word_b],
|
||||
"ending_match": word_a[-match_len:],
|
||||
"pattern": "consecutive" if offset == 1 else "alternating"
|
||||
})
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def estimate_structure(line_count: int) -> dict:
|
||||
"""Estimate structure category and needed sections from line count."""
|
||||
if line_count < 16:
|
||||
return {
|
||||
"estimated_structure": "short",
|
||||
"estimated_sections_needed": max(3, line_count // 4)
|
||||
}
|
||||
elif line_count <= 30:
|
||||
return {
|
||||
"estimated_structure": "medium",
|
||||
"estimated_sections_needed": max(5, line_count // 5)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"estimated_structure": "long",
|
||||
"estimated_sections_needed": max(7, line_count // 5)
|
||||
}
|
||||
|
||||
|
||||
def analyze_input(text: str) -> dict:
|
||||
"""Analyze input text and extract metrics."""
|
||||
lines = text.split('\n')
|
||||
non_empty_lines = [line for line in lines if line.strip()]
|
||||
content_lines = [line.strip() for line in lines if line.strip() and not re.match(r'^\[.*\]$', line.strip())]
|
||||
|
||||
# Detect metatags
|
||||
existing_tags = find_metatags(text)
|
||||
has_existing_structure = any(
|
||||
re.match(r'^(verse|chorus|bridge|intro|outro|pre-chorus|hook|refrain|breakdown|build-up)', tag.lower())
|
||||
for tag in existing_tags
|
||||
)
|
||||
|
||||
# Counts
|
||||
word_count = sum(len(line.split()) for line in content_lines)
|
||||
char_count = len(text)
|
||||
|
||||
# Repeated phrases
|
||||
repeated = find_repeated_phrases(text)
|
||||
|
||||
# Rhyme pairs
|
||||
rhymes = find_rhyme_pairs(text)
|
||||
|
||||
# Structure estimate (based on content lines)
|
||||
structure = estimate_structure(len(content_lines))
|
||||
|
||||
return {
|
||||
"has_existing_structure": has_existing_structure,
|
||||
"existing_tags": existing_tags,
|
||||
"line_count": len(lines),
|
||||
"non_empty_line_count": len(non_empty_lines),
|
||||
"word_count": word_count,
|
||||
"character_count": char_count,
|
||||
"repeated_phrases": repeated,
|
||||
"potential_rhyme_pairs": rhymes,
|
||||
**structure
|
||||
}
|
||||
|
||||
|
||||
def build_report(analysis: dict, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = []
|
||||
|
||||
if analysis["has_existing_structure"]:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "structure",
|
||||
"issue": "Input already contains section metatags.",
|
||||
"fix": "May need restructuring rather than initial structuring."
|
||||
})
|
||||
|
||||
if analysis["character_count"] > SUNO_LYRICS_HARD_LIMIT:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "length",
|
||||
"issue": f"Character count ({analysis['character_count']}) exceeds Suno's {SUNO_LYRICS_HARD_LIMIT}-character hard limit.",
|
||||
"fix": f"Trim to stay under {SUNO_LYRICS_HARD_LIMIT} characters. For best quality, aim for ~{SUNO_LYRICS_QUALITY_BUDGET}."
|
||||
})
|
||||
elif analysis["character_count"] > SUNO_LYRICS_QUALITY_BUDGET:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "length",
|
||||
"issue": f"Character count ({analysis['character_count']}) exceeds the ~{SUNO_LYRICS_QUALITY_BUDGET}-character quality budget.",
|
||||
"fix": f"Consider trimming — quality degrades above ~{SUNO_LYRICS_QUALITY_BUDGET} characters. Hard limit is {SUNO_LYRICS_HARD_LIMIT}."
|
||||
})
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["medium"] > 0:
|
||||
status = "info"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"has_existing_structure": analysis["has_existing_structure"],
|
||||
"existing_tags": analysis["existing_tags"],
|
||||
"line_count": analysis["line_count"],
|
||||
"non_empty_line_count": analysis["non_empty_line_count"],
|
||||
"word_count": analysis["word_count"],
|
||||
"character_count": analysis["character_count"],
|
||||
"repeated_phrases": analysis["repeated_phrases"],
|
||||
"potential_rhyme_pairs": analysis["potential_rhyme_pairs"],
|
||||
"estimated_structure": analysis["estimated_structure"],
|
||||
"estimated_sections_needed": analysis["estimated_sections_needed"],
|
||||
},
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pre-analyze raw input text to extract deterministic metrics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s input.txt
|
||||
%(prog)s --text "Some raw lyrics\\nAnother line"
|
||||
%(prog)s --stdin < input.txt
|
||||
%(prog)s input.txt -o results.json --verbose
|
||||
|
||||
Metrics extracted:
|
||||
- Existing metatags and structure detection
|
||||
- Line, word, and character counts
|
||||
- Repeated phrases (3+ words, 2+ occurrences)
|
||||
- Potential rhyme pairs (shared endings)
|
||||
- Estimated structure size (short/medium/long)
|
||||
|
||||
Exit codes: 0=pass, 1=issues, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to text file")
|
||||
parser.add_argument("--text", help="Text to analyze directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read text from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Analyzing input ({len(text)} chars, {len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
analysis = analyze_input(text)
|
||||
report = build_report(analysis, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
231
.agent/skills/suno-lyric-transformer/scripts/assemble-summary.py
Normal file
231
.agent/skills/suno-lyric-transformer/scripts/assemble-summary.py
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Assemble Transformation Summary from validation, syllable, and cliche reports.
|
||||
|
||||
Collects outputs from validate-lyrics.py, syllable-counter.py, and cliche-detector.py
|
||||
and assembles a formatted Transformation Summary markdown block.
|
||||
|
||||
Usage:
|
||||
python assemble-summary.py --validation val.json --syllables syl.json --cliches cli.json [options]
|
||||
|
||||
# Assemble from three JSON files
|
||||
python assemble-summary.py --validation val.json --syllables syl.json --cliches cli.json
|
||||
|
||||
# With transformation codes
|
||||
python assemble-summary.py --validation val.json --syllables syl.json --cliches cli.json --transformations "ST,CC,RA"
|
||||
|
||||
# Output to file
|
||||
python assemble-summary.py --validation val.json --syllables syl.json --cliches cli.json -o summary.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "assemble-summary"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
CODE_DESCRIPTIONS = {
|
||||
"ST": "Structural Transformation",
|
||||
"CE": "Cliche Elimination",
|
||||
"CC": "Consistency Check",
|
||||
"RA": "Rhyme Analysis",
|
||||
"FR": "Full Rewrite",
|
||||
"CD": "Cliche Detection",
|
||||
"WF": "Word Flow",
|
||||
}
|
||||
|
||||
# Approximate duration: ~15 seconds per section on average
|
||||
SECONDS_PER_SECTION = 15
|
||||
|
||||
|
||||
def load_json_file(path: str) -> dict:
|
||||
"""Load and parse a JSON file."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return {}
|
||||
return json.loads(p.read_text())
|
||||
|
||||
|
||||
def assemble_summary(validation: dict, syllables: dict, cliches: dict,
|
||||
transformations: list[str]) -> dict:
|
||||
"""Assemble summary data from the three reports."""
|
||||
# Extract from validation report
|
||||
val_metrics = validation.get("metrics", {})
|
||||
section_count = val_metrics.get("section_count", 0)
|
||||
section_types = val_metrics.get("sections", [])
|
||||
val_status = validation.get("status", "unknown")
|
||||
lyric_lines = val_metrics.get("lyric_lines", 0)
|
||||
total_lines = val_metrics.get("total_lines", 0)
|
||||
|
||||
# Estimate character count from validation raw data or total lines
|
||||
char_count = 0
|
||||
if "raw_text" in validation:
|
||||
char_count = len(validation["raw_text"])
|
||||
|
||||
# Extract from syllable report
|
||||
syl_metrics = syllables.get("metrics", {})
|
||||
min_syl = syl_metrics.get("min_syllables", 0)
|
||||
max_syl = syl_metrics.get("max_syllables", 0)
|
||||
avg_syl = syl_metrics.get("average_syllables_per_line", 0)
|
||||
total_syl = syl_metrics.get("total_syllables", 0)
|
||||
|
||||
# Extract from cliche report
|
||||
cli_metrics = cliches.get("metrics", {})
|
||||
total_cliches = cli_metrics.get("total_cliches_found", 0)
|
||||
cliche_categories = cli_metrics.get("categories", {})
|
||||
cli_status = cliches.get("status", "unknown")
|
||||
|
||||
# Estimated duration
|
||||
estimated_duration_sec = section_count * SECONDS_PER_SECTION
|
||||
minutes = estimated_duration_sec // 60
|
||||
seconds = estimated_duration_sec % 60
|
||||
|
||||
# Transformation descriptions
|
||||
trans_descriptions = [
|
||||
f"- {code}: {CODE_DESCRIPTIONS.get(code, code)}"
|
||||
for code in transformations
|
||||
]
|
||||
|
||||
return {
|
||||
"section_count": section_count,
|
||||
"section_types": section_types,
|
||||
"unique_section_types": sorted(set(
|
||||
s.split()[0] if ' ' in s else s for s in section_types
|
||||
)),
|
||||
"lyric_lines": lyric_lines,
|
||||
"total_lines": total_lines,
|
||||
"character_count": char_count,
|
||||
"syllable_range": f"{min_syl}-{max_syl}",
|
||||
"average_syllables": avg_syl,
|
||||
"total_syllables": total_syl,
|
||||
"estimated_duration": f"{minutes}:{seconds:02d}",
|
||||
"estimated_duration_sec": estimated_duration_sec,
|
||||
"total_cliches": total_cliches,
|
||||
"cliche_categories": cliche_categories,
|
||||
"cliche_status": cli_status,
|
||||
"validation_status": val_status,
|
||||
"transformations_applied": transformations,
|
||||
"transformation_descriptions": trans_descriptions,
|
||||
}
|
||||
|
||||
|
||||
def format_markdown(data: dict) -> str:
|
||||
"""Format the summary data as a markdown block."""
|
||||
lines = [
|
||||
"## Transformation Summary",
|
||||
"",
|
||||
f"**Validation Status:** {data['validation_status'].upper()}",
|
||||
f"**Sections:** {data['section_count']} ({', '.join(data['unique_section_types'])})",
|
||||
f"**Lyric Lines:** {data['lyric_lines']}",
|
||||
f"**Syllable Range:** {data['syllable_range']} (avg {data['average_syllables']})",
|
||||
f"**Estimated Duration:** ~{data['estimated_duration']}",
|
||||
]
|
||||
|
||||
if data['character_count'] > 0:
|
||||
lines.append(f"**Character Count:** {data['character_count']}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"**Cliche Detection:** {data['total_cliches']} found ({data['cliche_status']})")
|
||||
if data['cliche_categories']:
|
||||
for cat, count in sorted(data['cliche_categories'].items()):
|
||||
lines.append(f" - {cat}: {count}")
|
||||
|
||||
if data['transformations_applied']:
|
||||
lines.append("")
|
||||
lines.append("**Transformations Applied:**")
|
||||
for desc in data['transformation_descriptions']:
|
||||
lines.append(desc)
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_report(data: dict, markdown: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = []
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"metrics": data,
|
||||
"markdown": markdown,
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": 0,
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Assemble Transformation Summary from validation, syllable, and cliche reports.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --validation val.json --syllables syl.json --cliches cli.json
|
||||
%(prog)s --validation val.json --syllables syl.json --cliches cli.json --transformations "ST,CC,RA"
|
||||
%(prog)s --validation val.json --syllables syl.json --cliches cli.json -o summary.md --verbose
|
||||
|
||||
Exit codes: 0=pass, 1=issues, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("--validation", required=True, help="Path to validate-lyrics.py JSON output")
|
||||
parser.add_argument("--syllables", required=True, help="Path to syllable-counter.py JSON output")
|
||||
parser.add_argument("--cliches", required=True, help="Path to cliche-detector.py JSON output")
|
||||
parser.add_argument("--transformations", default="", help="Comma-separated transformation codes applied")
|
||||
parser.add_argument("--text", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("--stdin", action="store_true", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load input files
|
||||
validation = load_json_file(args.validation)
|
||||
syllables_data = load_json_file(args.syllables)
|
||||
cliches_data = load_json_file(args.cliches)
|
||||
|
||||
if not validation and not syllables_data and not cliches_data:
|
||||
print("Error: Could not load any input JSON files.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
transformations = [c.strip().upper() for c in args.transformations.split(",") if c.strip()] if args.transformations else []
|
||||
|
||||
if args.verbose:
|
||||
print(f"Assembling summary (transformations: {transformations})...", file=sys.stderr)
|
||||
|
||||
data = assemble_summary(validation, syllables_data, cliches_data, transformations)
|
||||
markdown = format_markdown(data)
|
||||
report = build_report(data, markdown, args.skill_path)
|
||||
|
||||
# Decide output format
|
||||
if args.output:
|
||||
out_path = Path(args.output)
|
||||
if out_path.suffix == ".json":
|
||||
out_path.write_text(json.dumps(report, indent=2))
|
||||
else:
|
||||
out_path.write_text(markdown)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(markdown)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
270
.agent/skills/suno-lyric-transformer/scripts/cliche-detector.py
Normal file
270
.agent/skills/suno-lyric-transformer/scripts/cliche-detector.py
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Detect cliche phrases in song lyrics.
|
||||
|
||||
Scans lyrics against a curated list of overused songwriting phrases and
|
||||
returns flagged matches with line numbers and suggested alternatives.
|
||||
|
||||
Usage:
|
||||
python cliche-detector.py <lyrics-file> [options]
|
||||
|
||||
# Detect cliches in a file
|
||||
python cliche-detector.py lyrics.txt
|
||||
|
||||
# Detect from text argument
|
||||
python cliche-detector.py --text "Fire in my soul keeps burning bright"
|
||||
|
||||
# Output to file
|
||||
python cliche-detector.py lyrics.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "cliche-detector"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Cliche database: pattern -> category and alternatives
|
||||
# Patterns use word boundaries for accurate matching
|
||||
CLICHES = {
|
||||
# Nature/Weather cliches
|
||||
r"dance\s+in\s+the\s+rain": {
|
||||
"category": "nature",
|
||||
"alternatives": ["stand still in the downpour", "let the storm soak through", "walk the wet streets barefoot"]
|
||||
},
|
||||
r"light\s+(?:in|at)\s+(?:the\s+)?(?:end\s+of\s+the\s+)?tunnel": {
|
||||
"category": "nature",
|
||||
"alternatives": ["a crack in the wall letting day through", "the exit sign still glowing", "morning edging past the blinds"]
|
||||
},
|
||||
r"(?:a|the)\s+storm\s+(?:is\s+)?coming": {
|
||||
"category": "nature",
|
||||
"alternatives": ["the pressure's dropping", "sky's gone that green color", "the stillness before everything moves"]
|
||||
},
|
||||
r"garden\s+grow(?:ing|s)?\s+(?:through|in)\s+(?:the\s+)?rain": {
|
||||
"category": "nature",
|
||||
"alternatives": ["roots pushing through concrete", "something green where nothing should be", "growing sideways toward the light"]
|
||||
},
|
||||
# Fire/Passion cliches
|
||||
r"fire\s+in\s+my\s+soul": {
|
||||
"category": "passion",
|
||||
"alternatives": ["this ache that won't sit still", "something restless under my ribs", "a hum I can't turn off"]
|
||||
},
|
||||
r"burn(?:ing)?\s+(?:bright|inside|with\s+desire)": {
|
||||
"category": "passion",
|
||||
"alternatives": ["glowing like a wire", "running hot and quiet", "lit up from the inside out"]
|
||||
},
|
||||
r"(?:set|light)\s+(?:my|the)\s+world\s+on\s+fire": {
|
||||
"category": "passion",
|
||||
"alternatives": ["rearrange everything I know", "flip the table", "make the ground shake under me"]
|
||||
},
|
||||
r"spark\s+(?:that|which)\s+(?:ignit|light)": {
|
||||
"category": "passion",
|
||||
"alternatives": ["the moment it all shifted", "the first crack in the wall", "when the static cleared"]
|
||||
},
|
||||
# Heart/Emotional cliches
|
||||
r"broken\s+(?:heart|wings|dreams)": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["bent out of shape", "cracked but not split", "the pieces I keep finding"]
|
||||
},
|
||||
r"heart\s+of\s+gold": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["stubborn tenderness", "gentle past the rough", "kind in a way that costs them"]
|
||||
},
|
||||
r"(?:my|your|the)\s+heart\s+(?:is\s+)?(?:beating|racing|pounding)": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["blood drumming in my ears", "chest tight with the rush", "pulse in my fingertips"]
|
||||
},
|
||||
r"tear(?:s)?\s+(?:fall(?:ing)?|roll(?:ing)?)\s+down": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["eyes stinging", "wet face in the mirror", "salt on my lips"]
|
||||
},
|
||||
r"(?:mend|heal|fix)\s+(?:my|your|a)\s+broken\s+heart": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["learn to carry this differently", "stop picking at the wound", "let the scar do its work"]
|
||||
},
|
||||
# Strength/Resilience cliches
|
||||
r"stand(?:ing)?\s+tall": {
|
||||
"category": "strength",
|
||||
"alternatives": ["not flinching", "still here", "planted and refusing to move"]
|
||||
},
|
||||
r"rise\s+(?:from|above|out\s+of)\s+the\s+ashes": {
|
||||
"category": "strength",
|
||||
"alternatives": ["rebuild from the wreckage", "walk out of the rubble", "start with what's left"]
|
||||
},
|
||||
r"(?:light|darkness)\s+(?:in|at)\s+the\s+(?:end|darkest)": {
|
||||
"category": "strength",
|
||||
"alternatives": ["one clear note in all the noise", "a way through I didn't see before", "the moment the fog thins"]
|
||||
},
|
||||
r"never\s+give\s+up": {
|
||||
"category": "strength",
|
||||
"alternatives": ["keep dragging forward", "refuse to quit this", "stubborn enough to stay"]
|
||||
},
|
||||
r"stronger\s+(?:than|now)": {
|
||||
"category": "strength",
|
||||
"alternatives": ["built different now", "tougher in the broken places", "harder to knock down"]
|
||||
},
|
||||
# Love cliches
|
||||
r"you\s+complete\s+me": {
|
||||
"category": "love",
|
||||
"alternatives": ["you fill the gaps I didn't know I had", "with you the noise stops", "I make more sense next to you"]
|
||||
},
|
||||
r"love\s+(?:is\s+)?(?:a\s+)?(?:battlefield|drug|addiction)": {
|
||||
"category": "love",
|
||||
"alternatives": ["love is a habit I can't break", "love is the thing that rearranges the furniture", "love is showing up when it's inconvenient"]
|
||||
},
|
||||
r"(?:my|our)\s+love\s+(?:is\s+)?(?:forever|eternal|undying)": {
|
||||
"category": "love",
|
||||
"alternatives": ["this thing between us doesn't have an off switch", "we keep finding our way back", "stubborn love that won't let go"]
|
||||
},
|
||||
r"lost\s+(?:in|without)\s+(?:your|those)\s+eyes": {
|
||||
"category": "love",
|
||||
"alternatives": ["caught in your attention", "held by the way you look", "frozen when you notice me"]
|
||||
},
|
||||
# Journey/Path cliches
|
||||
r"(?:long|winding)\s+(?:road|path|journey)": {
|
||||
"category": "journey",
|
||||
"alternatives": ["all these miles of wrong turns", "the route that kept changing", "following the bread crumbs"]
|
||||
},
|
||||
r"(?:find|finding|found)\s+(?:my|your|the)\s+way\s+(?:home|back)": {
|
||||
"category": "journey",
|
||||
"alternatives": ["recognize these streets again", "remember where the door is", "follow the familiar sounds"]
|
||||
},
|
||||
r"chasing\s+(?:dreams|the\s+sun|shadows)": {
|
||||
"category": "journey",
|
||||
"alternatives": ["running toward something unnamed", "following the pull", "reaching for what keeps moving"]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_cliches(text: str) -> list[dict]:
|
||||
"""Scan text for cliche phrases and return matches."""
|
||||
findings = []
|
||||
lines = text.split('\n')
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
# Skip metatags
|
||||
if not stripped or re.match(r'^\[.*\]$', stripped):
|
||||
continue
|
||||
|
||||
for pattern, info in CLICHES.items():
|
||||
match = re.search(pattern, stripped, re.IGNORECASE)
|
||||
if match:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "cliche",
|
||||
"location": {"line": i, "column": match.start()},
|
||||
"issue": f"Cliche phrase detected: '{match.group()}'",
|
||||
"fix": f"Consider alternatives: {' | '.join(info['alternatives'])}",
|
||||
"data": {
|
||||
"matched_text": match.group(),
|
||||
"cliche_category": info["category"],
|
||||
"alternatives": info["alternatives"],
|
||||
"full_line": stripped
|
||||
}
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def build_report(findings: list, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if len(findings) > 5:
|
||||
status = "warning"
|
||||
elif len(findings) > 0:
|
||||
status = "info" if len(findings) <= 2 else "warning"
|
||||
|
||||
# Categorize findings
|
||||
categories = {}
|
||||
for f in findings:
|
||||
cat = f.get("data", {}).get("cliche_category", "unknown")
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"total_cliches_found": len(findings),
|
||||
"categories": categories
|
||||
},
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect cliche phrases in song lyrics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s lyrics.txt
|
||||
%(prog)s --text "Fire in my soul keeps burning bright"
|
||||
%(prog)s --stdin < lyrics.txt
|
||||
%(prog)s lyrics.txt -o results.json --verbose
|
||||
|
||||
Exit codes: 0=no cliches, 1=cliches found, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to lyrics text file")
|
||||
parser.add_argument("--text", help="Lyrics text to scan directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read lyrics from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Scanning for cliches ({len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
findings = detect_cliches(text)
|
||||
report = build_report(findings, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if len(findings) == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
248
.agent/skills/suno-lyric-transformer/scripts/lyrics-diff.py
Normal file
248
.agent/skills/suno-lyric-transformer/scripts/lyrics-diff.py
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Produce structured diff between original and transformed lyrics.
|
||||
|
||||
Compares two versions of lyrics and categorizes changes by type (added,
|
||||
removed, modified) and tracks which sections they fall in.
|
||||
|
||||
Usage:
|
||||
python lyrics-diff.py --original orig.txt --transformed trans.txt [options]
|
||||
|
||||
# Compare two files
|
||||
python lyrics-diff.py --original orig.txt --transformed trans.txt
|
||||
|
||||
# Compare two text strings
|
||||
python lyrics-diff.py --original-text "old lyrics" --transformed-text "new lyrics"
|
||||
|
||||
# Output to file
|
||||
python lyrics-diff.py --original orig.txt --transformed trans.txt -o diff.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "lyrics-diff"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
def get_section_at_line(lines: list[str], line_idx: int) -> str:
|
||||
"""Determine which section a given line index falls in."""
|
||||
current_section = "(no section)"
|
||||
for i in range(line_idx + 1):
|
||||
if i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
tag_match = re.match(r'^\[([^\]:]+)\]$', stripped)
|
||||
if tag_match:
|
||||
current_section = tag_match.group(1).strip()
|
||||
return current_section
|
||||
|
||||
|
||||
def compute_diff(original: str, transformed: str) -> dict:
|
||||
"""Compute structured diff between original and transformed lyrics."""
|
||||
orig_lines = original.split('\n')
|
||||
trans_lines = transformed.split('\n')
|
||||
|
||||
matcher = difflib.SequenceMatcher(None, orig_lines, trans_lines)
|
||||
changes = []
|
||||
sections_affected = set()
|
||||
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == 'equal':
|
||||
continue
|
||||
elif tag == 'replace':
|
||||
# Modified lines
|
||||
max_len = max(i2 - i1, j2 - j1)
|
||||
for k in range(max_len):
|
||||
orig_idx = i1 + k if k < (i2 - i1) else None
|
||||
trans_idx = j1 + k if k < (j2 - j1) else None
|
||||
|
||||
if orig_idx is not None and trans_idx is not None:
|
||||
section = get_section_at_line(orig_lines, orig_idx)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "modified",
|
||||
"section": section,
|
||||
"line": orig_idx + 1,
|
||||
"original": orig_lines[orig_idx],
|
||||
"transformed": trans_lines[trans_idx]
|
||||
})
|
||||
elif orig_idx is not None:
|
||||
section = get_section_at_line(orig_lines, orig_idx)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "removed",
|
||||
"section": section,
|
||||
"line": orig_idx + 1,
|
||||
"original": orig_lines[orig_idx],
|
||||
"transformed": ""
|
||||
})
|
||||
elif trans_idx is not None:
|
||||
section = get_section_at_line(trans_lines, trans_idx)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "added",
|
||||
"section": section,
|
||||
"line": trans_idx + 1,
|
||||
"original": "",
|
||||
"transformed": trans_lines[trans_idx]
|
||||
})
|
||||
elif tag == 'delete':
|
||||
for k in range(i1, i2):
|
||||
section = get_section_at_line(orig_lines, k)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "removed",
|
||||
"section": section,
|
||||
"line": k + 1,
|
||||
"original": orig_lines[k],
|
||||
"transformed": ""
|
||||
})
|
||||
elif tag == 'insert':
|
||||
for k in range(j1, j2):
|
||||
section = get_section_at_line(trans_lines, k)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "added",
|
||||
"section": section,
|
||||
"line": k + 1,
|
||||
"original": "",
|
||||
"transformed": trans_lines[k]
|
||||
})
|
||||
|
||||
# Generate unified diff for human-readable output
|
||||
unified = list(difflib.unified_diff(
|
||||
orig_lines, trans_lines,
|
||||
fromfile="original", tofile="transformed",
|
||||
lineterm=""
|
||||
))
|
||||
|
||||
summary = {
|
||||
"lines_added": sum(1 for c in changes if c["type"] == "added"),
|
||||
"lines_removed": sum(1 for c in changes if c["type"] == "removed"),
|
||||
"lines_modified": sum(1 for c in changes if c["type"] == "modified"),
|
||||
"sections_affected": sorted(sections_affected)
|
||||
}
|
||||
|
||||
return {
|
||||
"changes": changes,
|
||||
"unified_diff": "\n".join(unified),
|
||||
"summary": summary
|
||||
}
|
||||
|
||||
|
||||
def build_report(result: dict, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
total_changes = len(result["changes"])
|
||||
|
||||
status = "pass"
|
||||
if total_changes == 0:
|
||||
status = "pass"
|
||||
else:
|
||||
status = "info"
|
||||
|
||||
findings = []
|
||||
if total_changes == 0:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "diff",
|
||||
"issue": "No differences found between original and transformed lyrics.",
|
||||
"fix": "Lyrics are identical."
|
||||
})
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"changes": result["changes"],
|
||||
"unified_diff": result["unified_diff"],
|
||||
"summary": result["summary"],
|
||||
"findings": findings,
|
||||
"finding_counts": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Produce structured diff between original and transformed lyrics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --original orig.txt --transformed trans.txt
|
||||
%(prog)s --original-text "old lyrics" --transformed-text "new lyrics"
|
||||
%(prog)s --original orig.txt --transformed trans.txt -o diff.json --verbose
|
||||
|
||||
Exit codes: 0=pass, 1=differences found, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("--original", help="Path to original lyrics file")
|
||||
parser.add_argument("--transformed", help="Path to transformed lyrics file")
|
||||
parser.add_argument("--original-text", help="Original lyrics text directly")
|
||||
parser.add_argument("--transformed-text", help="Transformed lyrics text directly")
|
||||
parser.add_argument("--text", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("--stdin", action="store_true", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
original = ""
|
||||
transformed = ""
|
||||
|
||||
if args.original_text and args.transformed_text:
|
||||
original = args.original_text.replace('\\n', '\n')
|
||||
transformed = args.transformed_text.replace('\\n', '\n')
|
||||
elif args.original and args.transformed:
|
||||
orig_path = Path(args.original)
|
||||
trans_path = Path(args.transformed)
|
||||
if not orig_path.exists():
|
||||
print(f"Error: File not found: {args.original}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if not trans_path.exists():
|
||||
print(f"Error: File not found: {args.transformed}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
original = orig_path.read_text()
|
||||
transformed = trans_path.read_text()
|
||||
else:
|
||||
print("Error: Provide --original and --transformed files, or --original-text and --transformed-text.", file=sys.stderr)
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Comparing lyrics (original: {len(original)} chars, transformed: {len(transformed)} chars)...", file=sys.stderr)
|
||||
|
||||
result = compute_diff(original, transformed)
|
||||
report = build_report(result, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if len(result["changes"]) == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Check section content lengths against expected ranges from the section-jobs framework.
|
||||
|
||||
Parses lyrics by metatag headers and validates that each section falls within
|
||||
recommended line count ranges for Suno compatibility.
|
||||
|
||||
Usage:
|
||||
python section-length-checker.py <lyrics-file> [options]
|
||||
|
||||
# Check section lengths in a file
|
||||
python section-length-checker.py lyrics.txt
|
||||
|
||||
# Check from text argument
|
||||
python section-length-checker.py --text "[Verse 1]\\nLine one\\nLine two"
|
||||
|
||||
# Output to file
|
||||
python section-length-checker.py lyrics.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "section-length-checker"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Expected line count ranges per section type (min, max)
|
||||
SECTION_RANGES = {
|
||||
"intro": (0, 4),
|
||||
"verse": (4, 8),
|
||||
"pre-chorus": (2, 4),
|
||||
"chorus": (2, 6),
|
||||
"bridge": (2, 6),
|
||||
"breakdown": (2, 4),
|
||||
"build-up": (2, 4),
|
||||
"outro": (2, 6),
|
||||
"hook": (1, 4),
|
||||
"refrain": (2, 6),
|
||||
"interlude": (0, 4),
|
||||
"post-chorus": (2, 4),
|
||||
"solo": (0, 2),
|
||||
"guitar solo": (0, 2),
|
||||
"piano solo": (0, 2),
|
||||
"sax solo": (0, 2),
|
||||
"saxophone solo": (0, 2),
|
||||
"drum solo": (0, 2),
|
||||
"bass solo": (0, 2),
|
||||
"instrumental": (0, 4),
|
||||
"build": (2, 4),
|
||||
"drop": (0, 4),
|
||||
"break": (0, 4),
|
||||
"end": (0, 4),
|
||||
"fade out": (0, 4),
|
||||
"fade in": (0, 4),
|
||||
}
|
||||
|
||||
|
||||
def normalize_section_name(tag: str) -> str:
|
||||
"""Normalize section tag to base name: 'Verse 1' -> 'verse', 'Final Chorus' -> 'chorus'."""
|
||||
tag_lower = tag.lower().strip()
|
||||
# Strip trailing numbers
|
||||
tag_lower = re.sub(r'\s*\d+$', '', tag_lower)
|
||||
# Handle "final chorus" -> "chorus"
|
||||
tag_lower = re.sub(r'^final\s+', '', tag_lower)
|
||||
return tag_lower.strip()
|
||||
|
||||
|
||||
def parse_sections(text: str) -> list[dict]:
|
||||
"""Parse lyrics into sections with line counts."""
|
||||
lines = text.split('\n')
|
||||
sections = []
|
||||
current_section = None
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# Check for section metatag
|
||||
tag_match = re.match(r'^\[([^\]:]+)\]$', stripped)
|
||||
if tag_match:
|
||||
tag_content = tag_match.group(1).strip()
|
||||
# Skip descriptor metatags (contain colon)
|
||||
if ':' in tag_content:
|
||||
continue
|
||||
# Save previous section
|
||||
if current_section is not None:
|
||||
sections.append(current_section)
|
||||
current_section = {
|
||||
"tag": tag_content,
|
||||
"base_name": normalize_section_name(tag_content),
|
||||
"lyric_lines": []
|
||||
}
|
||||
continue
|
||||
|
||||
# Check for descriptor metatags like [Energy: slow] — don't count as content
|
||||
descriptor_match = re.match(r'^\[[^\]]*:[^\]]*\]$', stripped)
|
||||
if descriptor_match:
|
||||
continue
|
||||
|
||||
# Non-empty, non-tag line goes into current section
|
||||
if stripped and current_section is not None:
|
||||
current_section["lyric_lines"].append(stripped)
|
||||
|
||||
# Don't forget last section
|
||||
if current_section is not None:
|
||||
sections.append(current_section)
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
# Genres that get relaxed section length constraints
|
||||
PROG_GENRES = {"prog", "metal", "progressive", "experimental"}
|
||||
|
||||
|
||||
def check_sections(text: str, genre: str = "") -> dict:
|
||||
"""Check section lengths against expected ranges."""
|
||||
sections = parse_sections(text)
|
||||
findings = []
|
||||
section_results = []
|
||||
is_prog = genre.lower() in PROG_GENRES if genre else False
|
||||
|
||||
for section in sections:
|
||||
line_count = len(section["lyric_lines"])
|
||||
base = section["base_name"]
|
||||
expected = SECTION_RANGES.get(base)
|
||||
# In prog/metal mode, double the max for all sections
|
||||
if expected and is_prog:
|
||||
expected = (expected[0], expected[1] * 2)
|
||||
|
||||
result = {
|
||||
"tag": section["tag"],
|
||||
"base_name": base,
|
||||
"line_count": line_count,
|
||||
"expected_range": list(expected) if expected else None,
|
||||
"status": "unknown"
|
||||
}
|
||||
|
||||
if expected is None:
|
||||
result["status"] = "unknown"
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "section-length",
|
||||
"location": {"section": section["tag"]},
|
||||
"issue": f"Section [{section['tag']}] has no defined expected range.",
|
||||
"fix": "This section type is not in the standard range database."
|
||||
})
|
||||
elif line_count < expected[0]:
|
||||
result["status"] = "short"
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "section-length",
|
||||
"location": {"section": section["tag"]},
|
||||
"issue": f"Section [{section['tag']}] is too short: {line_count} lines (expected {expected[0]}-{expected[1]}).",
|
||||
"fix": f"Add {expected[0] - line_count} more line(s) to reach the minimum of {expected[0]}."
|
||||
})
|
||||
elif line_count > expected[1]:
|
||||
result["status"] = "long"
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "section-length",
|
||||
"location": {"section": section["tag"]},
|
||||
"issue": f"Section [{section['tag']}] is too long: {line_count} lines (expected {expected[0]}-{expected[1]}).",
|
||||
"fix": f"Remove {line_count - expected[1]} line(s) to reach the maximum of {expected[1]}."
|
||||
})
|
||||
else:
|
||||
result["status"] = "pass"
|
||||
|
||||
section_results.append(result)
|
||||
|
||||
return {
|
||||
"sections": section_results,
|
||||
"findings": findings
|
||||
}
|
||||
|
||||
|
||||
def build_report(result: dict, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = result["findings"]
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
passed = sum(1 for s in result["sections"] if s["status"] == "pass")
|
||||
failed = sum(1 for s in result["sections"] if s["status"] in ("short", "long"))
|
||||
|
||||
status = "pass"
|
||||
if failed > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"total_sections": len(result["sections"]),
|
||||
"sections_pass": passed,
|
||||
"sections_fail": failed,
|
||||
},
|
||||
"sections": result["sections"],
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check section content lengths against expected ranges.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s lyrics.txt
|
||||
%(prog)s --text "[Verse 1]\\nLine 1\\nLine 2\\nLine 3\\nLine 4"
|
||||
%(prog)s --stdin < lyrics.txt
|
||||
%(prog)s lyrics.txt -o results.json --verbose
|
||||
|
||||
Expected ranges (lines):
|
||||
Intro=0-4, Verse=4-8, Pre-Chorus=2-4, Chorus=2-6,
|
||||
Bridge=2-6, Breakdown=2-4, Build-Up=2-4, Outro=2-6,
|
||||
Hook=1-4, Refrain=2-6
|
||||
|
||||
Exit codes: 0=pass, 1=issues, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to lyrics text file")
|
||||
parser.add_argument("--text", help="Lyrics text to check directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read lyrics from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
parser.add_argument("--genre", default="", help="Genre hint (prog, metal, progressive, experimental) to relax length constraints")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Checking section lengths ({len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
result = check_sections(text, genre=args.genre)
|
||||
report = build_report(result, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
383
.agent/skills/suno-lyric-transformer/scripts/syllable-counter.py
Normal file
383
.agent/skills/suno-lyric-transformer/scripts/syllable-counter.py
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Count syllables per line and analyze rhythmic consistency in lyrics.
|
||||
|
||||
Uses a heuristic syllable counting algorithm (vowel cluster method with
|
||||
common English adjustments). Not perfect, but reliable enough for
|
||||
songwriting guidance — consistent to within +/- 1 syllable per line.
|
||||
|
||||
Usage:
|
||||
python syllable-counter.py <lyrics-file> [options]
|
||||
|
||||
# Count syllables in a file
|
||||
python syllable-counter.py lyrics.txt
|
||||
|
||||
# Count from text argument
|
||||
python syllable-counter.py --text "Walking through the fog of morning"
|
||||
|
||||
# Output to file
|
||||
python syllable-counter.py lyrics.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "syllable-counter"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Common words with known syllable counts that the algorithm gets wrong
|
||||
SYLLABLE_OVERRIDES = {
|
||||
"the": 1, "every": 3, "different": 3, "evening": 3, "heaven": 2,
|
||||
"beautiful": 3, "comfortable": 3, "interesting": 4, "chocolate": 3,
|
||||
"fire": 2, "hour": 2, "flower": 2, "power": 2, "tower": 2,
|
||||
"desire": 3, "inspire": 3, "higher": 2, "liar": 2, "wire": 2,
|
||||
"quiet": 2, "lion": 2, "riot": 2, "diary": 3, "science": 2,
|
||||
"poem": 2, "being": 2, "seeing": 2, "doing": 2, "going": 2,
|
||||
"cruel": 2, "fuel": 2, "jewel": 2, "real": 1, "deal": 1,
|
||||
"people": 2, "little": 2, "middle": 2, "simple": 2, "able": 2,
|
||||
"maybe": 2, "somewhere": 2, "nowhere": 2, "everywhere": 3,
|
||||
"i'm": 1, "you're": 1, "we're": 1, "they're": 1, "he's": 1,
|
||||
"she's": 1, "it's": 1, "don't": 1, "won't": 1, "can't": 1,
|
||||
"couldn't": 2, "wouldn't": 2, "shouldn't": 2, "didn't": 2,
|
||||
"isn't": 2, "wasn't": 2, "aren't": 2, "weren't": 2,
|
||||
}
|
||||
|
||||
|
||||
def count_syllables(word: str) -> int:
|
||||
"""Count syllables in a single word using vowel cluster heuristic."""
|
||||
word = word.lower().strip()
|
||||
|
||||
# Remove non-alpha except apostrophes
|
||||
word = re.sub(r"[^a-z']", "", word)
|
||||
|
||||
if not word:
|
||||
return 0
|
||||
|
||||
# Check overrides first
|
||||
if word in SYLLABLE_OVERRIDES:
|
||||
return SYLLABLE_OVERRIDES[word]
|
||||
|
||||
# Vowel cluster counting with adjustments
|
||||
vowels = "aeiouy"
|
||||
count = 0
|
||||
prev_vowel = False
|
||||
|
||||
for i, char in enumerate(word):
|
||||
is_vowel = char in vowels
|
||||
if is_vowel and not prev_vowel:
|
||||
count += 1
|
||||
prev_vowel = is_vowel
|
||||
|
||||
# Adjustments
|
||||
# Silent e at end
|
||||
if word.endswith('e') and not word.endswith(('le', 'ce', 'se', 'ge', 'ze', 'ne', 'me', 've', 'te', 'de', 'be', 'fe', 'he', 'ke', 'pe', 'we', 'ye')):
|
||||
count -= 1
|
||||
elif word.endswith('e') and len(word) > 3 and word[-2] not in vowels:
|
||||
count -= 1
|
||||
|
||||
# -ed ending (usually not a syllable unless preceded by t or d)
|
||||
if word.endswith('ed') and len(word) > 3:
|
||||
if word[-3] not in ('t', 'd'):
|
||||
count -= 1
|
||||
|
||||
# -le at end is usually a syllable
|
||||
if word.endswith('le') and len(word) > 2 and word[-3] not in vowels:
|
||||
count += 1
|
||||
|
||||
# -es ending
|
||||
if word.endswith('es') and len(word) > 3:
|
||||
if word[-3] in ('s', 'z', 'x', 'ch', 'sh'):
|
||||
pass # -es IS a syllable here
|
||||
elif word[-3] not in vowels:
|
||||
count -= 1
|
||||
|
||||
# Ensure at least 1 syllable for any word
|
||||
return max(1, count)
|
||||
|
||||
|
||||
def count_line_syllables(line: str) -> int:
|
||||
"""Count total syllables in a line of text."""
|
||||
# Remove metatags
|
||||
line = re.sub(r'\[.*?\]', '', line)
|
||||
words = line.split()
|
||||
return sum(count_syllables(w) for w in words)
|
||||
|
||||
|
||||
def estimate_duration(total_lines: int, avg_syllables: float, sections: list = None) -> tuple:
|
||||
"""Estimate song duration based on lyrics structure and instrumental sections.
|
||||
|
||||
Returns (min_seconds, max_seconds) tuple.
|
||||
|
||||
Factors:
|
||||
- Lyric lines: ~3-5 seconds per line depending on syllable density
|
||||
- Instrumental sections (Intro, Outro, Solo, Breakdown, Build-Up):
|
||||
add time with no lyric lines
|
||||
- Suno typically generates 2-4 min songs from moderate lyrics
|
||||
|
||||
NOTE: This is a rough estimate. Actual Suno output varies significantly
|
||||
based on tempo, model, style prompt, and generation randomness.
|
||||
"""
|
||||
if total_lines == 0:
|
||||
return (0, 0)
|
||||
|
||||
# Base time from lyric lines
|
||||
# Denser syllables = faster delivery = less time per line
|
||||
if avg_syllables > 10:
|
||||
secs_per_line_min, secs_per_line_max = 2.5, 4.0
|
||||
elif avg_syllables > 7:
|
||||
secs_per_line_min, secs_per_line_max = 3.0, 4.5
|
||||
else:
|
||||
secs_per_line_min, secs_per_line_max = 3.5, 5.5
|
||||
|
||||
lyric_min = round(total_lines * secs_per_line_min)
|
||||
lyric_max = round(total_lines * secs_per_line_max)
|
||||
|
||||
# Add time for instrumental sections
|
||||
# These appear as section tags but contribute no lyric lines
|
||||
INSTRUMENTAL_TAGS = {
|
||||
"intro": (5, 15),
|
||||
"outro": (8, 20),
|
||||
"guitar solo": (10, 25),
|
||||
"solo": (10, 25),
|
||||
"instrumental": (10, 25),
|
||||
"breakdown": (8, 20),
|
||||
"build-up": (5, 15),
|
||||
"interlude": (8, 20),
|
||||
"drum solo": (8, 20),
|
||||
"sax solo": (10, 25),
|
||||
"piano solo": (10, 25),
|
||||
}
|
||||
|
||||
instrumental_min = 0
|
||||
instrumental_max = 0
|
||||
if sections:
|
||||
for section in sections:
|
||||
section_name = section.get("name", "").strip("[]").lower()
|
||||
for tag, (t_min, t_max) in INSTRUMENTAL_TAGS.items():
|
||||
if tag in section_name:
|
||||
instrumental_min += t_min
|
||||
instrumental_max += t_max
|
||||
break
|
||||
|
||||
# Also check for [Hummed] or empty-content sections that still take time
|
||||
if sections:
|
||||
for section in sections:
|
||||
section_name = section.get("name", "").strip("[]").lower()
|
||||
if "hummed" in section_name or "whistled" in section_name:
|
||||
instrumental_min += 5
|
||||
instrumental_max += 15
|
||||
|
||||
min_seconds = lyric_min + instrumental_min
|
||||
max_seconds = lyric_max + instrumental_max
|
||||
|
||||
return (min_seconds, max_seconds)
|
||||
|
||||
|
||||
def format_duration(seconds: int) -> str:
|
||||
"""Format seconds as M:SS."""
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
|
||||
def format_duration_range(min_seconds: int, max_seconds: int) -> str:
|
||||
"""Format a duration range as 'M:SS-M:SS'."""
|
||||
return f"{format_duration(min_seconds)}-{format_duration(max_seconds)}"
|
||||
|
||||
|
||||
def analyze_lyrics(text: str) -> dict:
|
||||
"""Analyze lyrics for syllable counts and rhythmic consistency."""
|
||||
lines = text.split('\n')
|
||||
line_data = []
|
||||
sections = []
|
||||
current_section = {"name": "ungrouped", "lines": []}
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
|
||||
# Check for section tag
|
||||
tag_match = re.match(r'^\[([^\]:]+?)(?:\s*\d*)?\]$', stripped)
|
||||
if tag_match and ':' not in stripped:
|
||||
# Start new section
|
||||
if current_section["lines"]:
|
||||
sections.append(current_section)
|
||||
current_section = {"name": stripped, "lines": []}
|
||||
continue
|
||||
|
||||
# Skip empty lines and descriptor metatags
|
||||
if not stripped or re.match(r'^\[.*:.*\]$', stripped):
|
||||
continue
|
||||
|
||||
syllables = count_line_syllables(stripped)
|
||||
entry = {
|
||||
"line_number": i,
|
||||
"text": stripped,
|
||||
"syllables": syllables,
|
||||
"word_count": len(stripped.split())
|
||||
}
|
||||
line_data.append(entry)
|
||||
current_section["lines"].append(entry)
|
||||
|
||||
# Don't forget last section
|
||||
if current_section["lines"]:
|
||||
sections.append(current_section)
|
||||
|
||||
# Analyze per-section consistency
|
||||
section_analysis = []
|
||||
findings = []
|
||||
|
||||
for section in sections:
|
||||
if not section["lines"]:
|
||||
continue
|
||||
|
||||
counts = [line["syllables"] for line in section["lines"]]
|
||||
avg = sum(counts) / len(counts)
|
||||
min_c = min(counts)
|
||||
max_c = max(counts)
|
||||
spread = max_c - min_c
|
||||
|
||||
analysis = {
|
||||
"section": section["name"],
|
||||
"line_count": len(counts),
|
||||
"syllable_counts": counts,
|
||||
"average": round(avg, 1),
|
||||
"min": min_c,
|
||||
"max": max_c,
|
||||
"spread": spread
|
||||
}
|
||||
section_analysis.append(analysis)
|
||||
|
||||
# Flag high variance within a section (spread > 2x the average line)
|
||||
if spread > avg and len(counts) > 2:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "rhythm",
|
||||
"location": {"section": section["name"]},
|
||||
"issue": f"High syllable variance in {section['name']}: range {min_c}-{max_c} (avg {avg:.0f}). This may cause uneven vocal phrasing.",
|
||||
"fix": f"Try to keep lines within a {int(avg)-2}-{int(avg)+2} syllable range for smoother singing.",
|
||||
"data": {"section": section["name"], "counts": counts, "average": round(avg, 1)}
|
||||
})
|
||||
|
||||
# Overall metrics
|
||||
all_counts = [entry["syllables"] for entry in line_data]
|
||||
overall_avg = sum(all_counts) / len(all_counts) if all_counts else 0
|
||||
|
||||
# Duration estimation (accounts for instrumental sections)
|
||||
min_sec, max_sec = estimate_duration(len(line_data), overall_avg, sections)
|
||||
duration_info = {
|
||||
"min_seconds": min_sec,
|
||||
"max_seconds": max_sec,
|
||||
"formatted": format_duration_range(min_sec, max_sec),
|
||||
"note": "Rough estimate — actual Suno output varies based on tempo, model, style prompt, and generation randomness. Instrumental sections, solos, and intros/outros add time beyond what lyrics alone suggest."
|
||||
}
|
||||
|
||||
return {
|
||||
"line_data": line_data,
|
||||
"section_analysis": section_analysis,
|
||||
"overall": {
|
||||
"total_lyric_lines": len(line_data),
|
||||
"total_syllables": sum(all_counts),
|
||||
"average_syllables_per_line": round(overall_avg, 1),
|
||||
"min_syllables": min(all_counts) if all_counts else 0,
|
||||
"max_syllables": max(all_counts) if all_counts else 0,
|
||||
"estimated_duration": duration_info
|
||||
},
|
||||
"findings": findings
|
||||
}
|
||||
|
||||
|
||||
def build_report(analysis: dict, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = analysis["findings"]
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["high"] > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": analysis["overall"],
|
||||
"line_data": analysis["line_data"],
|
||||
"section_analysis": analysis["section_analysis"],
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Count syllables per line and analyze rhythmic consistency in lyrics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s lyrics.txt
|
||||
%(prog)s --text "Walking through the fog of morning"
|
||||
%(prog)s --stdin < lyrics.txt
|
||||
%(prog)s lyrics.txt -o results.json --verbose
|
||||
|
||||
Exit codes: 0=pass, 1=rhythm issues found, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to lyrics text file")
|
||||
parser.add_argument("--text", help="Lyrics text to analyze directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read lyrics from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
parser.add_argument("--estimate-duration", action="store_true", help="Show estimated duration prominently")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Analyzing syllables ({len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
analysis = analyze_lyrics(text)
|
||||
report = build_report(analysis, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Configure pytest to collect test files with hyphens in names."""
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_collect_file(parent, file_path):
|
||||
if file_path.suffix == ".py" and file_path.name.startswith("test-"):
|
||||
return pytest.Module.from_parent(parent, path=file_path)
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for analyze-input.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "analyze-input.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestAnalyzeInput:
|
||||
def test_basic_metrics(self):
|
||||
text = "Hello world\nThis is a test\nThree lines here"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["line_count"] == 3
|
||||
assert m["non_empty_line_count"] == 3
|
||||
assert m["word_count"] == 9
|
||||
assert m["character_count"] > 0
|
||||
|
||||
def test_detects_existing_structure(self):
|
||||
text = "[Verse 1]\nSome lyrics here\nMore lyrics\n\n[Chorus]\nChorus line"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["has_existing_structure"] is True
|
||||
assert "Verse 1" in m["existing_tags"]
|
||||
assert "Chorus" in m["existing_tags"]
|
||||
|
||||
def test_no_structure_detected(self):
|
||||
text = "Just raw text\nWith no brackets\nPlain poetry"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["has_existing_structure"] is False
|
||||
assert m["existing_tags"] == []
|
||||
|
||||
def test_repeated_phrases(self):
|
||||
text = "come back to me tonight\nwhen the stars are bright\ncome back to me tonight\nunder the pale moonlight"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
phrases = [p["phrase"] for p in m["repeated_phrases"]]
|
||||
assert any("come back to me" in p for p in phrases)
|
||||
|
||||
def test_rhyme_pairs(self):
|
||||
text = "Walking down the street\nFeeling the beat\nLooking for the light\nShining in the night"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
rhymes = m["potential_rhyme_pairs"]
|
||||
rhyme_words = [set(r["words"]) for r in rhymes]
|
||||
assert any({"street", "beat"} == w for w in rhyme_words) or any({"light", "night"} == w for w in rhyme_words)
|
||||
|
||||
def test_short_structure_estimate(self):
|
||||
text = "\n".join(f"Line {i}" for i in range(1, 10))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "short"
|
||||
|
||||
def test_medium_structure_estimate(self):
|
||||
text = "\n".join(f"Line number {i} of the song" for i in range(1, 25))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "medium"
|
||||
|
||||
def test_long_structure_estimate(self):
|
||||
text = "\n".join(f"Line number {i} of a very long song" for i in range(1, 35))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "long"
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--text", "Some text")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "analyze" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for assemble-summary.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "assemble-summary.py")
|
||||
|
||||
|
||||
def run_script(*args, input_data=None):
|
||||
"""Run the script and return stdout and returncode."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True,
|
||||
input=input_data
|
||||
)
|
||||
return result.stdout, result.returncode
|
||||
|
||||
|
||||
def create_test_files(tmp_path):
|
||||
"""Create sample JSON input files for testing."""
|
||||
validation = {
|
||||
"script": "validate-lyrics",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_lines": 20,
|
||||
"lyric_lines": 14,
|
||||
"section_count": 4,
|
||||
"sections": ["Verse 1", "Chorus", "Verse 2", "Chorus"]
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0}
|
||||
}
|
||||
|
||||
syllables = {
|
||||
"script": "syllable-counter",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_lyric_lines": 14,
|
||||
"total_syllables": 112,
|
||||
"average_syllables_per_line": 8.0,
|
||||
"min_syllables": 5,
|
||||
"max_syllables": 12
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0}
|
||||
}
|
||||
|
||||
cliches = {
|
||||
"script": "cliche-detector",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_cliches_found": 2,
|
||||
"categories": {"emotional": 1, "nature": 1}
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 2}
|
||||
}
|
||||
|
||||
val_file = tmp_path / "validation.json"
|
||||
syl_file = tmp_path / "syllables.json"
|
||||
cli_file = tmp_path / "cliches.json"
|
||||
|
||||
val_file.write_text(json.dumps(validation))
|
||||
syl_file.write_text(json.dumps(syllables))
|
||||
cli_file.write_text(json.dumps(cliches))
|
||||
|
||||
return str(val_file), str(syl_file), str(cli_file)
|
||||
|
||||
|
||||
class TestAssembleSummary:
|
||||
def test_basic_assembly(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "Transformation Summary" in output
|
||||
assert "Validation Status" in output
|
||||
assert "Sections:" in output
|
||||
|
||||
def test_with_transformations(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"--transformations", "ST,CC,RA"
|
||||
)
|
||||
assert code == 0
|
||||
assert "Transformations Applied" in output
|
||||
assert "ST:" in output
|
||||
assert "CC:" in output
|
||||
assert "RA:" in output
|
||||
|
||||
def test_json_output(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
out_file = tmp_path / "output.json"
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"-o", str(out_file)
|
||||
)
|
||||
assert code == 0
|
||||
report = json.loads(out_file.read_text())
|
||||
assert report["script"] == "assemble-summary"
|
||||
assert "metrics" in report
|
||||
assert "markdown" in report
|
||||
|
||||
def test_markdown_output_file(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
out_file = tmp_path / "output.md"
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"-o", str(out_file)
|
||||
)
|
||||
assert code == 0
|
||||
content = out_file.read_text()
|
||||
assert "## Transformation Summary" in content
|
||||
|
||||
def test_cliche_categories_displayed(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "2 found" in output
|
||||
assert "emotional" in output
|
||||
assert "nature" in output
|
||||
|
||||
def test_syllable_range_displayed(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "5-12" in output
|
||||
assert "avg 8.0" in output
|
||||
|
||||
def test_estimated_duration(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
# 4 sections * 15 sec = 60 sec = 1:00
|
||||
assert "1:00" in output
|
||||
|
||||
def test_missing_files_handled(self, tmp_path):
|
||||
missing = str(tmp_path / "nonexistent.json")
|
||||
output, code = run_script(
|
||||
"--validation", missing, "--syllables", missing, "--cliches", missing
|
||||
)
|
||||
assert code == 2
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "assemble" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for cliche-detector.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "cliche-detector.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestClicheDetector:
|
||||
def test_detects_fire_in_soul(self):
|
||||
report, code = run_script("--text", "There's a fire in my soul tonight")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
assert any("fire in my soul" in f["data"]["matched_text"] for f in report["findings"])
|
||||
|
||||
def test_detects_dance_in_rain(self):
|
||||
report, code = run_script("--text", "We'll dance in the rain together")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_detects_broken_heart(self):
|
||||
report, code = run_script("--text", "My broken heart won't heal")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_detects_stand_tall(self):
|
||||
report, code = run_script("--text", "I'm standing tall against the wind")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_no_cliches_in_clean_text(self):
|
||||
report, code = run_script("--text", "The kitchen table holds three plates\nSteam rising from the coffee cup")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] == 0
|
||||
assert code == 0
|
||||
|
||||
def test_skips_metatags(self):
|
||||
text = "[Verse 1]\nFire in my soul\n[Chorus]\nClean lyrics here"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
# Should find the cliche in the lyric line, not in metatags
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_provides_alternatives(self):
|
||||
report, code = run_script("--text", "Rise from the ashes of what we were")
|
||||
assert report is not None
|
||||
assert len(report["findings"]) > 0
|
||||
finding = report["findings"][0]
|
||||
assert "alternatives" in finding["data"]
|
||||
assert len(finding["data"]["alternatives"]) > 0
|
||||
|
||||
def test_multiple_cliches_in_one_text(self):
|
||||
text = (
|
||||
"Fire in my soul keeps burning bright\n"
|
||||
"Standing tall through broken dreams\n"
|
||||
"Dance in the rain with a heart of gold\n"
|
||||
)
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 3
|
||||
|
||||
def test_case_insensitive(self):
|
||||
report, code = run_script("--text", "FIRE IN MY SOUL")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--text", "Just a normal line")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "cliche" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for lyrics-diff.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "lyrics-diff.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestLyricsDiff:
|
||||
def test_identical_lyrics(self):
|
||||
text = "[Verse 1]\nHello world\nGoodbye moon"
|
||||
report, code = run_script("--original-text", text, "--transformed-text", text)
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert len(report["changes"]) == 0
|
||||
assert report["summary"]["lines_added"] == 0
|
||||
assert report["summary"]["lines_removed"] == 0
|
||||
assert report["summary"]["lines_modified"] == 0
|
||||
|
||||
def test_modified_line(self):
|
||||
original = "[Verse 1]\nWalking through the rain"
|
||||
transformed = "[Verse 1]\nRunning through the storm"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert report["status"] == "info"
|
||||
modified = [c for c in report["changes"] if c["type"] == "modified"]
|
||||
assert len(modified) >= 1
|
||||
assert report["summary"]["lines_modified"] >= 1
|
||||
|
||||
def test_added_lines(self):
|
||||
original = "[Verse 1]\nLine one"
|
||||
transformed = "[Verse 1]\nLine one\nLine two\nLine three"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
added = [c for c in report["changes"] if c["type"] == "added"]
|
||||
assert len(added) >= 1
|
||||
assert report["summary"]["lines_added"] >= 1
|
||||
|
||||
def test_removed_lines(self):
|
||||
original = "[Verse 1]\nLine one\nLine two\nLine three"
|
||||
transformed = "[Verse 1]\nLine one"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
removed = [c for c in report["changes"] if c["type"] == "removed"]
|
||||
assert len(removed) >= 1
|
||||
assert report["summary"]["lines_removed"] >= 1
|
||||
|
||||
def test_section_tracking(self):
|
||||
original = "[Verse 1]\nOld verse line\n\n[Chorus]\nOld chorus line"
|
||||
transformed = "[Verse 1]\nNew verse line\n\n[Chorus]\nNew chorus line"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert len(report["summary"]["sections_affected"]) >= 1
|
||||
|
||||
def test_unified_diff_output(self):
|
||||
original = "[Verse 1]\nHello"
|
||||
transformed = "[Verse 1]\nGoodbye"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert "unified_diff" in report
|
||||
assert len(report["unified_diff"]) > 0
|
||||
|
||||
def test_file_input(self, tmp_path):
|
||||
orig_file = tmp_path / "orig.txt"
|
||||
trans_file = tmp_path / "trans.txt"
|
||||
orig_file.write_text("[Verse 1]\nOriginal line")
|
||||
trans_file.write_text("[Verse 1]\nTransformed line")
|
||||
report, code = run_script("--original", str(orig_file), "--transformed", str(trans_file))
|
||||
assert report is not None
|
||||
assert len(report["changes"]) >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--original-text", "a", "--transformed-text", "b")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "changes" in report
|
||||
assert "summary" in report
|
||||
assert "unified_diff" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "diff" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for section-length-checker.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "section-length-checker.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestSectionLengthChecker:
|
||||
def test_sections_within_range(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Line one of the verse\n"
|
||||
"Line two of the verse\n"
|
||||
"Line three of the verse\n"
|
||||
"Line four of the verse\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus line one\n"
|
||||
"Chorus line two\n"
|
||||
"Chorus line three\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert report["metrics"]["sections_pass"] == 2
|
||||
assert report["metrics"]["sections_fail"] == 0
|
||||
|
||||
def test_verse_too_short(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Only one line\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus one\n"
|
||||
"Chorus two\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["status"] == "warning"
|
||||
short_sections = [s for s in report["sections"] if s["status"] == "short"]
|
||||
assert len(short_sections) >= 1
|
||||
assert short_sections[0]["base_name"] == "verse"
|
||||
|
||||
def test_verse_too_long(self):
|
||||
lyrics = "[Verse 1]\n" + "\n".join(f"Line {i}" for i in range(1, 12)) + "\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
long_sections = [s for s in report["sections"] if s["status"] == "long"]
|
||||
assert len(long_sections) >= 1
|
||||
|
||||
def test_intro_can_be_empty(self):
|
||||
lyrics = (
|
||||
"[Intro]\n"
|
||||
"\n"
|
||||
"[Verse 1]\n"
|
||||
"Line one\nLine two\nLine three\nLine four\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
intro = [s for s in report["sections"] if s["base_name"] == "intro"]
|
||||
assert len(intro) == 1
|
||||
assert intro[0]["status"] == "pass"
|
||||
|
||||
def test_numbered_sections_normalized(self):
|
||||
lyrics = (
|
||||
"[Verse 2]\n"
|
||||
"Line one\nLine two\nLine three\nLine four\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus one\nChorus two\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
verse = [s for s in report["sections"] if s["tag"] == "Verse 2"]
|
||||
assert len(verse) == 1
|
||||
assert verse[0]["base_name"] == "verse"
|
||||
|
||||
def test_unknown_section_type(self):
|
||||
lyrics = "[Spoken Word]\nSome content\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
unknown = [s for s in report["sections"] if s["status"] == "unknown"]
|
||||
assert len(unknown) >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nLine one\nLine two\nLine three\nLine four\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "sections" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "section" in result.stdout.lower()
|
||||
|
||||
def test_descriptor_metatags_not_counted_as_content(self):
|
||||
"""Descriptor metatags like [Energy: slow] should not inflate line counts."""
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"[Energy: slow]\n"
|
||||
"[Vocal Style: clean]\n"
|
||||
"[Mood: dark]\n"
|
||||
"Line one of the verse\n"
|
||||
"Line two of the verse\n"
|
||||
"Line three of the verse\n"
|
||||
"Line four of the verse\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
verse = [s for s in report["sections"] if s["base_name"] == "verse"]
|
||||
assert len(verse) == 1
|
||||
# Should count only 4 lyric lines, not 7
|
||||
assert verse[0]["line_count"] == 4
|
||||
assert verse[0]["status"] == "pass"
|
||||
|
||||
def test_prog_genre_relaxes_verse_limit(self):
|
||||
"""With --genre prog, verses can have up to 16 lines without warning."""
|
||||
lines = "\n".join(f"Line {i}" for i in range(1, 13))
|
||||
lyrics = f"[Verse 1]\n{lines}\n"
|
||||
# Without genre flag, 12 lines should be too long (max 8)
|
||||
report_normal, _ = run_script("--text", lyrics)
|
||||
assert report_normal is not None
|
||||
verse_normal = [s for s in report_normal["sections"] if s["base_name"] == "verse"]
|
||||
assert verse_normal[0]["status"] == "long"
|
||||
|
||||
# With prog genre, 12 lines should pass (max becomes 16)
|
||||
report_prog, _ = run_script("--text", lyrics, "--genre", "prog")
|
||||
assert report_prog is not None
|
||||
verse_prog = [s for s in report_prog["sections"] if s["base_name"] == "verse"]
|
||||
assert verse_prog[0]["status"] == "pass"
|
||||
|
||||
def test_interlude_is_known_section(self):
|
||||
"""Interlude should now be a known section type with defined range."""
|
||||
lyrics = "[Interlude]\nSome content\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
interlude = [s for s in report["sections"] if s["base_name"] == "interlude"]
|
||||
assert len(interlude) == 1
|
||||
assert interlude[0]["status"] == "pass"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for syllable-counter.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "syllable-counter.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
# Also test the count_syllables function directly
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location("syllable_counter", Path(__file__).parent.parent / "syllable-counter.py")
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
count_syllables = _mod.count_syllables
|
||||
estimate_duration = _mod.estimate_duration
|
||||
format_duration_range = _mod.format_duration_range
|
||||
|
||||
|
||||
class TestSyllableCounting:
|
||||
"""Test individual word syllable counting."""
|
||||
|
||||
def test_one_syllable_words(self):
|
||||
for word in ["cat", "dog", "the", "run", "light", "dream"]:
|
||||
assert count_syllables(word) == 1, f"Expected 1 syllable for '{word}', got {count_syllables(word)}"
|
||||
|
||||
def test_two_syllable_words(self):
|
||||
for word in ["hello", "window", "walking", "morning", "shadow"]:
|
||||
assert count_syllables(word) == 2, f"Expected 2 syllables for '{word}', got {count_syllables(word)}"
|
||||
|
||||
def test_three_syllable_words(self):
|
||||
for word in ["beautiful", "another", "everyone", "different"]:
|
||||
result = count_syllables(word)
|
||||
assert result == 3, f"Expected 3 syllables for '{word}', got {result}"
|
||||
|
||||
def test_contractions(self):
|
||||
assert count_syllables("I'm") == 1
|
||||
assert count_syllables("don't") == 1
|
||||
assert count_syllables("couldn't") == 2
|
||||
|
||||
def test_empty_string(self):
|
||||
assert count_syllables("") == 0
|
||||
|
||||
|
||||
class TestLyricsAnalysis:
|
||||
"""Test full lyrics analysis via the script."""
|
||||
|
||||
def test_basic_analysis(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["script"] == "syllable-counter"
|
||||
assert report["metrics"]["total_lyric_lines"] == 2
|
||||
assert report["metrics"]["total_syllables"] > 0
|
||||
|
||||
def test_section_grouping(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Short line here\n"
|
||||
"Another short one\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"The chorus comes in strong and bold\n"
|
||||
"With longer lines that carry more weight\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert len(report["section_analysis"]) == 2
|
||||
section_names = [s["section"] for s in report["section_analysis"]]
|
||||
assert "[Verse 1]" in section_names
|
||||
assert "[Chorus]" in section_names
|
||||
|
||||
def test_line_data_includes_syllables(self):
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert len(report["line_data"]) == 1
|
||||
assert "syllables" in report["line_data"][0]
|
||||
assert report["line_data"][0]["syllables"] > 0
|
||||
|
||||
def test_skips_metatags(self):
|
||||
lyrics = "[Mood: haunting]\n[Verse 1]\nWalking through fog\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Only the lyric line should be counted, not metatags
|
||||
assert report["metrics"]["total_lyric_lines"] == 1
|
||||
|
||||
def test_high_variance_warning(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Hi\n"
|
||||
"This is a much longer line with many more syllables than the first\n"
|
||||
"Short\n"
|
||||
"Another really long line that goes on and on and on\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Should flag high syllable variance
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("variance" in i.lower() or "syllable" in i.lower() for i in issues)
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nA simple test line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "line_data" in report
|
||||
assert "section_analysis" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "syllable" in result.stdout.lower()
|
||||
|
||||
|
||||
class TestDurationEstimation:
|
||||
"""Test duration estimation function."""
|
||||
|
||||
def test_zero_lines(self):
|
||||
min_s, max_s = estimate_duration(0, 0)
|
||||
assert min_s == 0
|
||||
assert max_s == 0
|
||||
|
||||
def test_one_line(self):
|
||||
# 7.0 avg syllables = mid range (3.0-4.5 secs/line)
|
||||
min_s, max_s = estimate_duration(1, 7.0)
|
||||
assert min_s == round(1 * 3.5) # low-density range
|
||||
assert max_s == round(1 * 5.5)
|
||||
|
||||
def test_typical_song(self):
|
||||
# 20 lines at 7.0 avg syllables (mid range)
|
||||
min_s, max_s = estimate_duration(20, 7.0)
|
||||
assert min_s == round(20 * 3.5) # 70
|
||||
assert max_s == round(20 * 5.5) # 110
|
||||
|
||||
def test_high_density_faster(self):
|
||||
# High syllable density = faster delivery = less time per line
|
||||
min_s, max_s = estimate_duration(20, 12.0)
|
||||
assert min_s == round(20 * 2.5) # 50
|
||||
assert max_s == round(20 * 4.0) # 80
|
||||
|
||||
def test_instrumental_sections_add_time(self):
|
||||
# Sections with instrumental tags add time
|
||||
sections = [
|
||||
{"name": "[Intro]", "lines": []},
|
||||
{"name": "[Verse]", "lines": [{"syllables": 7}] * 4},
|
||||
{"name": "[Guitar Solo]", "lines": []},
|
||||
{"name": "[Outro]", "lines": []},
|
||||
]
|
||||
min_s, max_s = estimate_duration(4, 7.0, sections)
|
||||
# 4 lines at mid range + intro (5-15) + guitar solo (10-25) + outro (8-20)
|
||||
assert min_s > round(4 * 3.5) # More than just lyrics
|
||||
assert max_s > round(4 * 5.5)
|
||||
|
||||
def test_formatted_range(self):
|
||||
formatted = format_duration_range(50, 90)
|
||||
assert formatted == "0:50-1:30"
|
||||
|
||||
def test_formatted_range_zero(self):
|
||||
formatted = format_duration_range(0, 0)
|
||||
assert formatted == "0:00-0:00"
|
||||
|
||||
def test_formatted_range_large(self):
|
||||
formatted = format_duration_range(120, 240)
|
||||
assert formatted == "2:00-4:00"
|
||||
|
||||
def test_duration_in_report(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
duration = report["metrics"]["estimated_duration"]
|
||||
assert "min_seconds" in duration
|
||||
assert "max_seconds" in duration
|
||||
assert "formatted" in duration
|
||||
assert duration["min_seconds"] > 0
|
||||
assert duration["max_seconds"] > duration["min_seconds"]
|
||||
# Check formatted string pattern M:SS-M:SS
|
||||
assert "-" in duration["formatted"]
|
||||
|
||||
def test_estimate_duration_flag(self):
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics, "--estimate-duration")
|
||||
assert report is not None
|
||||
assert "estimated_duration" in report["metrics"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for validate-lyrics.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "validate-lyrics.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestValidateLyrics:
|
||||
def test_valid_structured_lyrics(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
"\n"
|
||||
"[Verse 2]\n"
|
||||
"Fingerprints on frosted glass\n"
|
||||
"Letters folded into cranes\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["script"] == "validate-lyrics"
|
||||
assert report["metrics"]["section_count"] == 4
|
||||
assert "Verse 1" in report["metrics"]["sections"]
|
||||
assert "Chorus" in report["metrics"]["sections"]
|
||||
|
||||
def test_no_section_tags(self):
|
||||
lyrics = "Just some raw text\nWith no structure at all\nThree lines of poetry"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("No section metatags" in i for i in issues)
|
||||
|
||||
def test_style_cue_contamination(self):
|
||||
lyrics = "[Verse 1]\nThe punchy drums echo through my mind\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("style cue" in i.lower() for i in issues)
|
||||
|
||||
def test_asterisk_detection(self):
|
||||
lyrics = "[Verse 1]\n*This line has asterisks*\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Asterisk" in i for i in issues)
|
||||
|
||||
def test_empty_lyrics(self):
|
||||
report, code = run_script("--text", "")
|
||||
assert report is not None
|
||||
assert report["status"] == "fail"
|
||||
assert any(f["severity"] == "critical" for f in report["findings"])
|
||||
|
||||
def test_unrecognized_metatag(self):
|
||||
lyrics = "[Verse 1]\nSome line\n\n[Banana]\nAnother line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Unrecognized metatag" in i for i in issues)
|
||||
|
||||
def test_valid_descriptor_metatags(self):
|
||||
lyrics = (
|
||||
"[Mood: haunting]\n\n"
|
||||
"[Verse 1]\n"
|
||||
"Walking through the fog\n"
|
||||
"Counting all the windows\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Descriptor metatags should not be flagged as unrecognized
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert not any("Mood" in i and "Unrecognized" in i for i in issues)
|
||||
|
||||
def test_empty_section(self):
|
||||
lyrics = "[Verse 1]\n\n[Chorus]\nSome chorus line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Empty section" in i for i in issues)
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nA simple test line here\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "validate" in result.stdout.lower()
|
||||
|
||||
def test_character_count_error(self):
|
||||
"""Lyrics exceeding 5000 chars (hard limit) should produce high severity finding."""
|
||||
# Build lyrics over 5000 characters (hard limit for v4.5+/v5/v5.5)
|
||||
line = "This is a long line of lyrics for testing character count limits yeah\n"
|
||||
lyrics = "[Verse 1]\n" + line * 80 # well over 5000 chars
|
||||
assert len(lyrics) > 5000
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "character count" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert any(f["severity"] == "high" for f in issues)
|
||||
|
||||
def test_character_count_warning(self):
|
||||
"""Lyrics between 3000 and 5000 chars should produce medium severity finding (quality degrades)."""
|
||||
# Build lyrics between 3000 and 5000 characters (quality budget exceeded)
|
||||
line = "This is a medium line of lyrics for testing\n"
|
||||
base = "[Verse 1]\n"
|
||||
# Each line is 45 chars. Need total between 3000 and 5000.
|
||||
count = 72 # 10 + 72*45 = 3250
|
||||
lyrics = base + line * count
|
||||
total = len(lyrics)
|
||||
assert 3000 < total < 5000, f"Got {total} chars"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "character count" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert any(f["severity"] == "medium" for f in issues)
|
||||
|
||||
def test_character_count_in_metrics(self):
|
||||
"""Report metrics should include character_count."""
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "character_count" in report["metrics"]
|
||||
assert report["metrics"]["character_count"] == len(lyrics)
|
||||
|
||||
def test_punctuation_density_detection(self):
|
||||
"""Lines with heavy punctuation should trigger a rhythm finding."""
|
||||
lyrics = "[Verse 1]\nwell, - ; : ... yes\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "punctuation" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert issues[0]["severity"] == "low"
|
||||
assert issues[0]["category"] == "rhythm"
|
||||
|
||||
def test_clean_lyrics_normal_punctuation_passes(self):
|
||||
"""Clean lyrics with normal punctuation should pass without punctuation findings."""
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
"\n"
|
||||
"[Verse 2]\n"
|
||||
"Fingerprints on frosted glass\n"
|
||||
"Letters folded into cranes\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
punct_issues = [f for f in report["findings"] if "punctuation" in f["issue"].lower()]
|
||||
assert len(punct_issues) == 0
|
||||
|
||||
def test_new_section_tags_recognized(self):
|
||||
"""New section tags like Guitar Solo, Instrumental, etc. should not be flagged."""
|
||||
new_tags = [
|
||||
"Guitar Solo", "Piano Solo", "Sax Solo", "Saxophone Solo",
|
||||
"Drum Solo", "Bass Solo", "Solo", "Instrumental", "Interlude",
|
||||
"Build", "Build-Up", "Buildup", "Drop", "Hook", "Refrain",
|
||||
"Post-Chorus", "End", "Fade Out", "Fade In", "Break",
|
||||
]
|
||||
for tag in new_tags:
|
||||
lyrics = f"[Verse 1]\nSome line one\nSome line two\n\n[{tag}]\nContent here\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None, f"No report for [{tag}]"
|
||||
unrecognized = [f for f in report["findings"]
|
||||
if "Unrecognized" in f.get("issue", "") and tag in f.get("issue", "")]
|
||||
assert len(unrecognized) == 0, f"[{tag}] was flagged as unrecognized"
|
||||
|
||||
def test_vocal_cues_recognized(self):
|
||||
"""Vocal delivery cues like [Harmonized] should not be flagged as unrecognized."""
|
||||
cues = ["Harmonized", "Hummed", "Humming", "Whistled", "Whistling",
|
||||
"Crooning", "Scat", "Call and Response"]
|
||||
for cue in cues:
|
||||
lyrics = f"[Verse 1]\nSome line here\n[{cue}]\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None, f"No report for [{cue}]"
|
||||
unrecognized = [f for f in report["findings"]
|
||||
if "Unrecognized" in f.get("issue", "") and cue in f.get("issue", "")]
|
||||
assert len(unrecognized) == 0, f"[{cue}] was flagged as unrecognized"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for validate-options.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "validate-options.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestValidateOptions:
|
||||
def test_all_valid_codes(self):
|
||||
report, code = run_script("ST,CC,RA,CD")
|
||||
assert report is not None
|
||||
assert report["script"] == "validate-options"
|
||||
assert report["status"] == "pass"
|
||||
assert set(report["validated_codes"]) == {"ST", "CC", "RA", "CD"}
|
||||
assert report["removed_codes"] == []
|
||||
|
||||
def test_invalid_code(self):
|
||||
report, code = run_script("ST,ZZ,RA")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("ZZ" in i for i in issues)
|
||||
|
||||
def test_fr_wf_mutual_exclusion(self):
|
||||
report, code = run_script("FR,WF")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("mutually exclusive" in i.lower() for i in issues)
|
||||
|
||||
def test_fr_auto_removes_ce(self):
|
||||
report, code = run_script("FR,CE,RA")
|
||||
assert report is not None
|
||||
assert "CE" in report["removed_codes"]
|
||||
assert "CE" not in report["validated_codes"]
|
||||
assert "FR" in report["validated_codes"]
|
||||
assert "RA" in report["validated_codes"]
|
||||
|
||||
def test_ce_cc_info_note(self):
|
||||
report, code = run_script("CE,CC")
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("CC" in i and "redundant" in i.lower() for i in issues)
|
||||
# CC should still be in validated codes (info only, not removed)
|
||||
assert "CC" in report["validated_codes"]
|
||||
|
||||
def test_empty_codes(self):
|
||||
report, code = run_script("--codes", "")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
assert any(f["severity"] == "critical" for f in report["findings"])
|
||||
|
||||
def test_codes_flag(self):
|
||||
report, code = run_script("--codes", "ST,RA")
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert set(report["validated_codes"]) == {"ST", "RA"}
|
||||
|
||||
def test_duplicate_codes(self):
|
||||
report, code = run_script("ST,ST,RA")
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Duplicate" in i for i in issues)
|
||||
assert report["validated_codes"].count("ST") == 1
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "validate" in result.stdout.lower()
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("ST")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "validated_codes" in report
|
||||
assert "removed_codes" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
427
.agent/skills/suno-lyric-transformer/scripts/validate-lyrics.py
Normal file
427
.agent/skills/suno-lyric-transformer/scripts/validate-lyrics.py
Normal file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Validate transformed lyrics structure for Suno compatibility.
|
||||
|
||||
Checks metatag formatting, section structure, blank line separators,
|
||||
style cue contamination, and reasonable song length.
|
||||
|
||||
Usage:
|
||||
python validate-lyrics.py <lyrics-file-or-text> [options]
|
||||
|
||||
# Validate lyrics from a file
|
||||
python validate-lyrics.py lyrics.txt
|
||||
|
||||
# Validate lyrics from stdin
|
||||
echo "[Verse 1]\\nHello world" | python validate-lyrics.py --stdin
|
||||
|
||||
# Validate with text argument
|
||||
python validate-lyrics.py --text "[Verse 1]\\nHello world"
|
||||
|
||||
# Output to file
|
||||
python validate-lyrics.py lyrics.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import SUNO_LYRICS_HARD_LIMIT, SUNO_LYRICS_QUALITY_BUDGET
|
||||
|
||||
SCRIPT_NAME = "validate-lyrics"
|
||||
VERSION = "1.1.0"
|
||||
|
||||
# Valid section metatags (case-insensitive matching)
|
||||
VALID_SECTIONS = {
|
||||
"intro", "verse", "verse 1", "verse 2", "verse 3", "verse 4",
|
||||
"pre-chorus", "chorus", "bridge", "breakdown", "build-up", "buildup",
|
||||
"final chorus", "outro", "hook", "refrain", "interlude",
|
||||
"post-chorus", "solo",
|
||||
# Instrumental / solo variants
|
||||
"guitar solo", "piano solo", "sax solo", "saxophone solo",
|
||||
"drum solo", "bass solo", "instrumental",
|
||||
# Structural tags
|
||||
"build", "drop", "break", "end",
|
||||
"fade out", "fade in",
|
||||
}
|
||||
|
||||
# Valid vocal delivery cues (inline metatags, not section tags)
|
||||
VALID_VOCAL_CUES = {
|
||||
"harmonized", "hummed", "humming", "whistled", "whistling",
|
||||
"crooning", "scat", "call and response",
|
||||
}
|
||||
|
||||
# Valid descriptor metatag prefixes
|
||||
VALID_DESCRIPTORS = {"mood", "energy", "vocal style", "instrument", "tempo", "key"}
|
||||
|
||||
# HIGH-confidence standalone bare-bracket tags from metatag-reference.md
|
||||
# Kept in sync with the "Standalone Mood/Energy Tags" and "Timing & Rhythm Tags" sections.
|
||||
VALID_STANDALONE_MOODS = {
|
||||
"uplifting", "haunting", "dark", "nostalgic", "somber", "romantic",
|
||||
"dreamy", "peaceful", "anxious", "euphoric", "mysterious", "playful",
|
||||
"epic", "intimate", "bittersweet", "triumphant",
|
||||
}
|
||||
|
||||
VALID_STANDALONE_ENERGY = {
|
||||
"high energy", "medium energy", "low energy", "chill", "driving",
|
||||
"explosive", "building", "relaxed", "frantic", "steady",
|
||||
}
|
||||
|
||||
VALID_TIMING_RHYTHM = {
|
||||
"half-time", "swung feel", "shuffle", "triplet feel", "syncopated",
|
||||
"straight", "four on the floor", "polyrhythmic", "breakbeat",
|
||||
}
|
||||
|
||||
# Style cues that should NOT be in lyrics
|
||||
STYLE_CONTAMINATION_PATTERNS = [
|
||||
r'\b(?:BPM|bpm)\b',
|
||||
r'\b(?:stereo|mono)\s+(?:field|mix)\b',
|
||||
r'\b(?:radio[- ]ready|lo[- ]fi|hi[- ]fi)\b',
|
||||
r'\b(?:punchy|warm|crisp)\s+(?:drums|bass|mix|production)\b',
|
||||
]
|
||||
|
||||
# Reasonable song length bounds (in non-empty, non-tag lines)
|
||||
MIN_LYRIC_LINES = 8
|
||||
MAX_LYRIC_LINES = 80
|
||||
RECOMMENDED_MAX_SECTIONS = 12
|
||||
|
||||
|
||||
|
||||
def parse_lyrics(text: str) -> dict:
|
||||
"""Parse lyrics into structured sections with line data."""
|
||||
lines = text.split('\n')
|
||||
sections = []
|
||||
current_section = None
|
||||
all_tags = []
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
|
||||
# Check if this is a metatag
|
||||
tag_match = re.match(r'^\[([^\]]+)\]$', stripped)
|
||||
if tag_match:
|
||||
tag_content = tag_match.group(1).strip()
|
||||
all_tags.append({"text": tag_content, "line": i})
|
||||
|
||||
# Check if it's a descriptor (has a colon)
|
||||
if ':' in tag_content:
|
||||
prefix = tag_content.split(':')[0].strip().lower()
|
||||
if prefix in VALID_DESCRIPTORS:
|
||||
if current_section is None:
|
||||
# Global descriptor — fine
|
||||
pass
|
||||
# Descriptor attached to current/next section — fine
|
||||
continue
|
||||
|
||||
# Check if it's a section tag
|
||||
tag_lower = tag_content.lower()
|
||||
# Strip numbers for matching: "Verse 1" -> "verse 1", but also match base "verse"
|
||||
is_section = (tag_lower in VALID_SECTIONS or
|
||||
tag_lower in VALID_VOCAL_CUES or
|
||||
re.match(r'^(verse|chorus|bridge|breakdown|build-up|buildup|pre-chorus|post-chorus|hook|refrain|interlude|solo|instrumental|break|drop|build|end|fade\s*(?:out|in))\s*\d*$', tag_lower))
|
||||
|
||||
if is_section:
|
||||
current_section = {
|
||||
"tag": tag_content,
|
||||
"line": i,
|
||||
"lyric_lines": [],
|
||||
"lyric_line_numbers": []
|
||||
}
|
||||
sections.append(current_section)
|
||||
continue
|
||||
|
||||
# Non-tag, non-empty line
|
||||
if stripped:
|
||||
if current_section:
|
||||
current_section["lyric_lines"].append(stripped)
|
||||
current_section["lyric_line_numbers"].append(i)
|
||||
|
||||
return {
|
||||
"sections": sections,
|
||||
"all_tags": all_tags,
|
||||
"total_lines": len(lines),
|
||||
"raw_text": text
|
||||
}
|
||||
|
||||
|
||||
def validate_lyrics(text: str) -> list[dict]:
|
||||
"""Validate lyrics text and return findings."""
|
||||
findings = []
|
||||
lines = text.split('\n')
|
||||
|
||||
if not text.strip():
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"issue": "Lyrics text is empty.",
|
||||
"fix": "Provide lyrics with at least one section and content."
|
||||
})
|
||||
return findings
|
||||
|
||||
parsed = parse_lyrics(text)
|
||||
sections = parsed["sections"]
|
||||
|
||||
# Check for at least one section tag
|
||||
if not sections:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"issue": "No section metatags found. Suno uses tags like [Verse], [Chorus] to structure songs.",
|
||||
"fix": "Add section tags to define song structure."
|
||||
})
|
||||
|
||||
# Check for blank lines between sections
|
||||
for section in sections:
|
||||
line_num = section["line"]
|
||||
if line_num > 1:
|
||||
prev_line = lines[line_num - 2].strip() if line_num - 1 < len(lines) else ""
|
||||
if prev_line and not prev_line.startswith('['):
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"location": {"line": line_num},
|
||||
"issue": f"No blank line before section tag [{section['tag']}] at line {line_num}.",
|
||||
"fix": "Add a blank line before each section tag for cleaner Suno parsing."
|
||||
})
|
||||
|
||||
# Check for style cues in lyrics
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
if not stripped or re.match(r'^\[.*\]$', stripped):
|
||||
continue
|
||||
for pattern in STYLE_CONTAMINATION_PATTERNS:
|
||||
if re.search(pattern, stripped, re.IGNORECASE):
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"line": i},
|
||||
"issue": f"Possible style cue in lyrics at line {i}: '{stripped[:60]}...'",
|
||||
"fix": "Style descriptions belong in the style prompt, not in lyrics."
|
||||
})
|
||||
break
|
||||
|
||||
# Check for asterisks
|
||||
for i, line in enumerate(lines, 1):
|
||||
if '*' in line:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"location": {"line": i},
|
||||
"issue": f"Asterisk found in lyrics at line {i}. Suno doesn't use markdown.",
|
||||
"fix": "Remove asterisks from lyrics."
|
||||
})
|
||||
|
||||
# Count actual lyric lines (non-empty, non-tag)
|
||||
lyric_lines = [line.strip() for line in lines if line.strip() and not re.match(r'^\[.*\]$', line.strip())]
|
||||
lyric_count = len(lyric_lines)
|
||||
|
||||
if lyric_count < MIN_LYRIC_LINES:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"issue": f"Very short lyrics ({lyric_count} lines). May produce a very short song.",
|
||||
"fix": "Consider adding more content or sections for a full-length song."
|
||||
})
|
||||
|
||||
# Character count check (Suno counts everything including metatags)
|
||||
char_count = len(text)
|
||||
if char_count > SUNO_LYRICS_HARD_LIMIT:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"issue": f"Total character count ({char_count}) exceeds Suno's {SUNO_LYRICS_HARD_LIMIT}-character limit. Suno will truncate your lyrics.",
|
||||
"fix": "Trim lyrics to stay under 5,000 characters (hard limit). For best quality, aim for ~3,000 characters."
|
||||
})
|
||||
elif char_count > SUNO_LYRICS_QUALITY_BUDGET:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"issue": f"Total character count ({char_count}) is approaching Suno's {SUNO_LYRICS_HARD_LIMIT}-character limit.",
|
||||
"fix": "Consider trimming — quality degrades above ~3,000 characters. Hard limit is 5,000."
|
||||
})
|
||||
|
||||
if lyric_count > MAX_LYRIC_LINES:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"issue": f"Very long lyrics ({lyric_count} lines). Suno may not render all content.",
|
||||
"fix": "Consider trimming to a more standard song length (20-50 lyric lines)."
|
||||
})
|
||||
|
||||
# Check section count
|
||||
if len(sections) > RECOMMENDED_MAX_SECTIONS:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"issue": f"High section count ({len(sections)}). Songs typically have 6-10 sections.",
|
||||
"fix": "Consider consolidating sections for a cleaner structure."
|
||||
})
|
||||
|
||||
# Check for invalid metatags
|
||||
for tag_info in parsed["all_tags"]:
|
||||
tag_text = tag_info["text"]
|
||||
tag_lower = tag_text.lower()
|
||||
# Is it a valid section?
|
||||
is_section = (tag_lower in VALID_SECTIONS or
|
||||
re.match(r'^(verse|chorus|bridge|breakdown|build-up|buildup|pre-chorus|post-chorus|hook|refrain|interlude|solo|instrumental|break|drop|build|end|fade\s*(?:out|in))\s*\d*$', tag_lower))
|
||||
# Is it a valid vocal delivery cue?
|
||||
is_vocal_cue = tag_lower in VALID_VOCAL_CUES
|
||||
# Is it a valid descriptor?
|
||||
is_descriptor = ':' in tag_text and tag_text.split(':')[0].strip().lower() in VALID_DESCRIPTORS
|
||||
# Is it a HIGH-confidence standalone mood/energy/rhythm tag from metatag-reference.md?
|
||||
is_standalone = (tag_lower in VALID_STANDALONE_MOODS or
|
||||
tag_lower in VALID_STANDALONE_ENERGY or
|
||||
tag_lower in VALID_TIMING_RHYTHM)
|
||||
|
||||
if not is_section and not is_vocal_cue and not is_descriptor and not is_standalone:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"line": tag_info["line"]},
|
||||
"issue": f"Unrecognized metatag [{tag_text}] at line {tag_info['line']}. May not be interpreted by Suno.",
|
||||
"fix": "Use standard section tags or descriptor tags (Mood/Energy/Vocal Style/Instrument)."
|
||||
})
|
||||
|
||||
# Punctuation density check
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
if not stripped or re.match(r'^\[.*\]$', stripped):
|
||||
continue
|
||||
words = stripped.split()
|
||||
word_count = len(words)
|
||||
if word_count == 0:
|
||||
continue
|
||||
# Count commas, dashes, semicolons, colons, ellipses
|
||||
punct_count = (
|
||||
stripped.count(',') + stripped.count('-') + stripped.count(';')
|
||||
+ stripped.count(':') + stripped.count('...')
|
||||
)
|
||||
density = punct_count / word_count
|
||||
if density > 0.5:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "rhythm",
|
||||
"location": {"line": i},
|
||||
"issue": f"Heavy punctuation density ({density:.2f}) at line {i}: '{stripped[:60]}'. Heavy punctuation can confuse Suno's cadence.",
|
||||
"fix": "Simplify punctuation to let Suno interpret natural phrasing."
|
||||
})
|
||||
|
||||
# Check for empty sections
|
||||
for section in sections:
|
||||
if not section["lyric_lines"]:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"location": {"line": section["line"]},
|
||||
"issue": f"Empty section [{section['tag']}] at line {section['line']}.",
|
||||
"fix": "Add lyrics to this section or remove the tag if it's meant to be instrumental."
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def build_report(findings: list, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
for f in findings:
|
||||
if "location" not in f:
|
||||
f["location"] = {"file": "lyrics"}
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["critical"] > 0:
|
||||
status = "fail"
|
||||
elif severity_counts["high"] > 0:
|
||||
status = "warning"
|
||||
|
||||
parsed = parse_lyrics(text)
|
||||
lyric_lines = [line.strip() for line in text.split('\n')
|
||||
if line.strip() and not re.match(r'^\[.*\]$', line.strip())]
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"total_lines": parsed["total_lines"],
|
||||
"lyric_lines": len(lyric_lines),
|
||||
"character_count": len(text),
|
||||
"section_count": len(parsed["sections"]),
|
||||
"sections": [s["tag"] for s in parsed["sections"]]
|
||||
},
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate transformed lyrics structure for Suno compatibility.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s lyrics.txt
|
||||
%(prog)s --text "[Verse 1]\\nHello world"
|
||||
%(prog)s --stdin < lyrics.txt
|
||||
%(prog)s lyrics.txt -o results.json --verbose
|
||||
|
||||
Exit codes: 0=pass, 1=fail/warning, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to lyrics text file")
|
||||
parser.add_argument("--text", help="Lyrics text to validate directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read lyrics from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text is not None:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Validating lyrics ({len(text)} chars, {len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
findings = validate_lyrics(text)
|
||||
report = build_report(findings, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
224
.agent/skills/suno-lyric-transformer/scripts/validate-options.py
Normal file
224
.agent/skills/suno-lyric-transformer/scripts/validate-options.py
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Validate transformation option selections against mutual exclusion rules.
|
||||
|
||||
Checks that selected transformation option codes are valid and consistent,
|
||||
enforcing mutual exclusion and dependency rules between options.
|
||||
|
||||
Usage:
|
||||
python validate-options.py <option-codes> [options]
|
||||
|
||||
# Validate option codes from positional argument
|
||||
python validate-options.py "ST,CC,RA,CD"
|
||||
|
||||
# Validate with --codes flag
|
||||
python validate-options.py --codes "ST,CC,RA,CD"
|
||||
|
||||
# Output to file
|
||||
python validate-options.py "ST,CC,RA" -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "validate-options"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
VALID_CODES = {"ST", "CE", "CC", "RA", "FR", "CD", "WF"}
|
||||
|
||||
CODE_DESCRIPTIONS = {
|
||||
"ST": "Structural Transformation",
|
||||
"CE": "Cliche Elimination",
|
||||
"CC": "Consistency Check",
|
||||
"RA": "Rhyme Analysis",
|
||||
"FR": "Full Rewrite",
|
||||
"CD": "Cliche Detection",
|
||||
"WF": "Word Flow",
|
||||
}
|
||||
|
||||
|
||||
def validate_options(codes_str: str) -> dict:
|
||||
"""Validate option codes and return results with findings."""
|
||||
raw_codes = [c.strip().upper() for c in codes_str.split(",") if c.strip()]
|
||||
findings = []
|
||||
removed_codes = []
|
||||
validated_codes = []
|
||||
|
||||
if not raw_codes:
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "validation",
|
||||
"issue": "No option codes provided.",
|
||||
"fix": "Provide at least one valid option code: " + ", ".join(sorted(VALID_CODES))
|
||||
})
|
||||
return {
|
||||
"validated_codes": [],
|
||||
"removed_codes": [],
|
||||
"findings": findings
|
||||
}
|
||||
|
||||
# Check for invalid codes
|
||||
invalid = [c for c in raw_codes if c not in VALID_CODES]
|
||||
valid_input = [c for c in raw_codes if c in VALID_CODES]
|
||||
|
||||
for code in invalid:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "validation",
|
||||
"issue": f"Invalid option code: '{code}'.",
|
||||
"fix": f"Valid codes are: {', '.join(sorted(VALID_CODES))}"
|
||||
})
|
||||
|
||||
# Check for duplicates
|
||||
seen = set()
|
||||
deduped = []
|
||||
for code in valid_input:
|
||||
if code in seen:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "validation",
|
||||
"issue": f"Duplicate option code: '{code}'.",
|
||||
"fix": "Each code should appear only once."
|
||||
})
|
||||
else:
|
||||
seen.add(code)
|
||||
deduped.append(code)
|
||||
|
||||
working = list(deduped)
|
||||
|
||||
# FR and WF are mutually exclusive
|
||||
if "FR" in working and "WF" in working:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "exclusion",
|
||||
"issue": "FR (Full Rewrite) and WF (Word Flow) are mutually exclusive.",
|
||||
"fix": "Choose either FR or WF, not both."
|
||||
})
|
||||
|
||||
# CE is skipped if FR is selected (warn, auto-remove CE)
|
||||
if "FR" in working and "CE" in working:
|
||||
working.remove("CE")
|
||||
removed_codes.append("CE")
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "dependency",
|
||||
"issue": "CE (Cliche Elimination) auto-removed: redundant when FR (Full Rewrite) is selected.",
|
||||
"fix": "FR already encompasses cliche elimination."
|
||||
})
|
||||
|
||||
# CC is skipped if CE is selected (info, can be overridden)
|
||||
if "CE" in working and "CC" in working:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "dependency",
|
||||
"issue": "CC (Consistency Check) may be redundant when CE (Cliche Elimination) is selected.",
|
||||
"fix": "CE may alter consistency; CC can still be kept if desired."
|
||||
})
|
||||
|
||||
validated_codes = working
|
||||
|
||||
return {
|
||||
"validated_codes": validated_codes,
|
||||
"removed_codes": removed_codes,
|
||||
"findings": findings
|
||||
}
|
||||
|
||||
|
||||
def build_report(result: dict, codes_str: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = result["findings"]
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["critical"] > 0 or severity_counts["high"] > 0:
|
||||
status = "error"
|
||||
elif severity_counts["medium"] > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"validated_codes": result["validated_codes"],
|
||||
"removed_codes": result["removed_codes"],
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate transformation option selections against mutual exclusion rules.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s "ST,CC,RA,CD"
|
||||
%(prog)s --codes "ST,CC,RA,CD"
|
||||
%(prog)s "FR,CE" -o results.json --verbose
|
||||
|
||||
Valid codes: ST, CE, CC, RA, FR, CD, WF
|
||||
Rules:
|
||||
- FR and WF are mutually exclusive
|
||||
- CE is auto-removed when FR is selected
|
||||
- CC info note when CE is selected
|
||||
|
||||
Exit codes: 0=pass, 1=issues, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Comma-separated option codes (positional)")
|
||||
parser.add_argument("--codes", help="Comma-separated option codes")
|
||||
parser.add_argument("--text", help="Alias for --codes (for consistency)")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read codes from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
codes_str = ""
|
||||
if args.codes is not None:
|
||||
codes_str = args.codes
|
||||
elif args.text is not None:
|
||||
codes_str = args.text
|
||||
elif args.stdin:
|
||||
codes_str = sys.stdin.read().strip()
|
||||
elif args.file:
|
||||
codes_str = args.file
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Validating option codes: {codes_str}", file=sys.stderr)
|
||||
|
||||
result = validate_options(codes_str)
|
||||
report = build_report(result, codes_str, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user