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
384 lines
13 KiB
Python
384 lines
13 KiB
Python
#!/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()
|