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

- 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:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

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

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

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

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

View File

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

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

View File

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

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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"])

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

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