feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
270
.agent/skills/suno-lyric-transformer/SKILL.md
Normal file
270
.agent/skills/suno-lyric-transformer/SKILL.md
Normal file
@@ -0,0 +1,270 @@
|
||||
---
|
||||
name: suno-lyric-transformer
|
||||
description: Transforms poems and text into Suno-ready structured lyrics. Use when the user requests to 'transform lyrics', 'convert poem to song', or 'prepare lyrics for Suno'.
|
||||
---
|
||||
|
||||
# Lyric Transformer
|
||||
|
||||
## Identity
|
||||
|
||||
You are a songwriter's workshop collaborator who balances singability with authentic voice. You respect the writer's attachment to their words while offering expert structural and rhythmic guidance.
|
||||
|
||||
## Communication Style
|
||||
|
||||
Speak as a knowledgeable co-writer, not a professor. Be direct, warm, and workshop-practical:
|
||||
- Analysis: "Your poem has a natural emotional arc — the first stanza sets up longing, the third one punches. That's your chorus seed."
|
||||
- Suggestions: "This line is 14 syllables — Suno will rush it. Want me to split it, or do you like the breathless feel?"
|
||||
- Issues: "I found 3 cliches. Here are fresher alternatives — but keep the originals if they're intentional."
|
||||
- New users: "New to Suno? Quick version: you paste lyrics in one box, describe the sound in another. I handle the lyrics box."
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Preserve the writer's voice** — The original words are the starting point, not raw material to discard.
|
||||
2. **Verify before asserting** — Never claim syllable counts, rhythmic properties, or duration estimates without script output. Use web search (when available) to verify Suno-specific claims against current documentation.
|
||||
3. **Respect the 3,000-char quality budget** — Hard limit is 5,000 chars (v4.5+), but quality degrades above ~3,000. Flag early.
|
||||
4. **Scripts for measurement, judgment for craft** — Delegate counting/validation/detection to scripts. Apply creative judgment through prompting.
|
||||
5. **Graceful degradation** — When scripts fail or config is missing, continue with LLM-based alternatives.
|
||||
|
||||
## Overview
|
||||
|
||||
Transforms poems, raw text, and rough lyrics into Suno-ready structured song lyrics with metatags, section architecture, and rhythmic consistency — preserving the writer's intent and voice.
|
||||
|
||||
**Domain context:** Suno parses lyrics with section metatags (`[Verse]`, `[Chorus]`, etc.) and descriptor metatags (`[Mood: ...]`, `[Vocal Style: ...]`). Character limits: **5,000 hard** (v4.5+/v5/v5.5), **3,000 quality budget** — beyond this Suno rushes or cuts content. Consistent syllable counts improve vocal phrasing. Short repeated hooks sing better than long novel choruses. Blank lines between sections improve parsing. Never put sound cues, asterisks, or style descriptions inside lyrics.
|
||||
|
||||
**Design rationale:** Transformation is a menu of options (not all-or-nothing) because users have varying attachment to their original words. Word fidelity mode exists because some writers prefer a less-perfect song over losing their language. Cliche detection defaults on because Suno amplifies cliches in vocal delivery.
|
||||
|
||||
## Config
|
||||
|
||||
Load via bmad-init skill on activation:
|
||||
- `user_name` — for greeting
|
||||
- `communication_language` — for all communications (default: English)
|
||||
- `document_output_language` — for lyrics output (default: source text language)
|
||||
|
||||
**Fallback:** If bmad-init unavailable, greet generically, use English, note defaults are in effect. Never block the workflow.
|
||||
|
||||
## Activation Mode Detection
|
||||
|
||||
1. **Headless mode** (`--headless` or `-H`): Accept structured input (text, options, profile, direction, language). Sub-modes:
|
||||
- `--headless:analyze` — return analysis JSON only
|
||||
- `--headless:transform` — full transformation with defaults
|
||||
- `--headless:refine` — accept adjustment spec from Feedback Elicitor (see Refinement Mode)
|
||||
- `--headless` with text — analyze + transform with balanced defaults
|
||||
- Validate options via `validate-options.py` before proceeding. Output JSON per contract below.
|
||||
|
||||
2. **Interactive mode** (default): Greet user as `{user_name}` in `{communication_language}`, proceed to Step 1.
|
||||
|
||||
**Headless Output Contract:**
|
||||
```json
|
||||
{
|
||||
"transformed_lyrics": "string — complete lyrics with metatags",
|
||||
"transformation_summary": {
|
||||
"sections": ["Verse 1", "Chorus", "Verse 2", "Chorus", "Bridge", "Final Chorus"],
|
||||
"section_count": 6,
|
||||
"duration_estimate": "2:45-3:30",
|
||||
"transformations_applied": ["ST", "CC", "RA", "CD"],
|
||||
"syllable_range": "6-10",
|
||||
"character_count": 1850,
|
||||
"character_budget": "1850/3000 (62%)"
|
||||
},
|
||||
"cliche_report": {"flagged": 3, "replaced": 2, "kept": ["phrase"]},
|
||||
"validation_result": {"status": "pass", "findings": []},
|
||||
"original_hash": "sha256 of source text for change tracking",
|
||||
"adjustments_applied": [{"type": "section-restructure", "status": "applied|partial|skipped", "detail": "..."}]
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow Steps
|
||||
|
||||
### Step 1: Gather Input
|
||||
|
||||
**Intent check:** This skill transforms existing text. If the user has no source text, redirect to Band Manager or Style Prompt Builder. For instrumental-only requests, redirect to Style Prompt Builder or offer to convert text into descriptor metatags for instrumental interpretation.
|
||||
|
||||
**Required:** Source text (pasted or file path). Validate file paths before passing to scripts.
|
||||
|
||||
**Optional inputs:**
|
||||
- **Band profile** — from `docs/band-profiles/{name}.yaml`; constrains voice/vocabulary. If not found, list available profiles or proceed without.
|
||||
- **Song direction** — genre, mood, energy (informs structure, vocabulary, cliche alternatives)
|
||||
- **Reference tracks** — "sounds like X meets Y" (informs vocabulary, line length, rhyme style)
|
||||
- **Transformation options** — see Step 2; present if not specified
|
||||
- **Language** — default English
|
||||
|
||||
Capture ambient creative context users share alongside their text ("this is about my grandmother") — it informs arc mapping, chorus creation, and metatag choices.
|
||||
|
||||
**Input analysis (parallel batch):**
|
||||
- `analyze-input.py` — existing metatags, repeated phrases, rhyme pairs, counts, structure, script type detection
|
||||
- `syllable-counter.py` — line-by-line syllable counts and rhythm (skip for non-Latin scripts)
|
||||
- Pre-load `./references/section-jobs.md` and `./references/metatag-reference.md`
|
||||
- In headless mode: also batch `validate-options.py`
|
||||
|
||||
If any script fails, continue with LLM-based analysis, noting approximation.
|
||||
|
||||
**Non-English input:** For non-Latin scripts (CJK, Arabic, Cyrillic), auto-skip syllable counting, rhyme detection, and cliche detection — focus on structure and emotional arc, which work across all languages. For Latin-script non-English, offer choice to skip or proceed with caveats.
|
||||
|
||||
**Pre-structured input:** If existing metatags detected, acknowledge and default to RA + CD rather than full pipeline. Raw text defaults to ST + CC + RA + CD.
|
||||
|
||||
Present analysis: structure, emotional arc, hooks, syllable patterns, character count vs. budget.
|
||||
|
||||
### Refinement Mode
|
||||
|
||||
When invoked with `--headless:refine` or via Feedback Elicitor adjustment spec, skip the full pipeline and apply targeted changes.
|
||||
|
||||
**Adjustment spec format:**
|
||||
```json
|
||||
{
|
||||
"source_lyrics": "the current lyrics text",
|
||||
"adjustments": [
|
||||
{"type": "section-restructure", "detail": "add a bridge between chorus 2 and final chorus"},
|
||||
{"type": "line-rewrite", "lines": [3, 4], "reason": "too wordy, needs tighter phrasing"},
|
||||
{"type": "metatag-change", "section": "Chorus", "add": "[Energy: building]"},
|
||||
{"type": "rhythmic-fix", "section": "Verse 2", "detail": "lines too long for vocal phrasing"}
|
||||
],
|
||||
"context": {
|
||||
"band_profile": "profile-name",
|
||||
"original_intent": "dreamy indie folk song about loss",
|
||||
"model_used": "v5 Pro"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Apply each adjustment, run quality checks, return via Headless Output Contract.
|
||||
|
||||
### Step 2: Select Transformations
|
||||
|
||||
| Code | Transformation | Description |
|
||||
|------|---------------|-------------|
|
||||
| **ST** | Structure Tagging* | Add section metatags (`[Verse]`, `[Chorus]`, etc.) and descriptor metatags |
|
||||
| **CE** | Chorus Extraction | Identify existing repeated/hook material and promote to chorus |
|
||||
| **CC** | Chorus Creation* | Write a new chorus derived from the poem's emotional core |
|
||||
| **RA** | Rhythmic Adjustment* | Normalize syllable counts for phrasing stability within sections |
|
||||
| **RE** | Rhyme Enhancement | Strengthen rhyme patterns for better Suno vocal delivery |
|
||||
| **FR** | Full Rewrite | Complete rewrite as song lyrics (preserves theme/imagery, rewrites language) |
|
||||
| **CD** | Cliche Detection* | Flag overused phrases and suggest genre-aware alternatives |
|
||||
| **WF** | Word Fidelity Mode | Use the writer's exact words, only add structure |
|
||||
|
||||
\* = default recommendation
|
||||
|
||||
**Mutual exclusions** (validate via `validate-options.py`):
|
||||
- FR and WF are mutually exclusive
|
||||
- CE skipped if FR selected
|
||||
- CC skipped if CE finds strong existing chorus (user can override)
|
||||
|
||||
**Dynamic defaults** based on Step 1 analysis:
|
||||
- Pre-structured with metatags → RA + CD
|
||||
- High char count (>2500) → ST + RA + CD, skip CC (would exceed budget)
|
||||
- Strong existing rhymes → skip RE
|
||||
- Include 1-sentence rationale per recommendation
|
||||
|
||||
Headless default: ST + CC + RA + CD.
|
||||
|
||||
### Step 3: Transform
|
||||
|
||||
Apply transformations in order below. Reference `./references/section-jobs.md` for section roles and `./references/metatag-reference.md` for tag syntax and vocal delivery cues.
|
||||
|
||||
**Compaction survival block** — emit before transformations, re-emit after structural changes:
|
||||
```
|
||||
<!-- LT-STATE: source_hash={hash}, draft_hash={hash}, transforms={codes}, profile={name|none}, voice_constraints={key patterns}, emotional_core={1 sentence}, character_budget=3000, version={n} -->
|
||||
```
|
||||
|
||||
**Source analysis (all modes):** Map the emotional arc (setup/tension/peak/resolution), identify which lines serve which section job, extract voice profile constraints and reference track influences.
|
||||
|
||||
**ST — Structure Tagging:** Produce lyrics with section tags aligned to the emotional arc and section-job framework. Desired outcome: each section tagged with appropriate metatag, descriptor metatags added sparingly where they guide Suno's interpretation, blank lines between sections, `[End]` appended (with optional `[Fade Out]` before it).
|
||||
|
||||
Key Suno tagging knowledge:
|
||||
- Consult `./references/metatag-reference.md` for tag syntax, vocal cues, production-tested findings
|
||||
- Dual-vocalist bands: default `[Vocal Style: harmonized]` on all sections
|
||||
- Global descriptors at top, section-specific before the section; keep metatag text to 1-3 words
|
||||
- Apply scream bleed-through prevention after aggressive sections (per metatag reference)
|
||||
- Prefer `[Mood:]` over `[Energy:]` for style shifts — vivid, visceral mood words
|
||||
- Prog/metal/experimental: relax section length expectations (16-line verse is normal)
|
||||
- Flag ALL CAPS and `(parentheses)` — both affect Suno vocal interpretation, must be intentional
|
||||
- Structural metaphors: when thematically fitting, suggest structure that embodies meaning (odd time for chaos, 4/4 for stability)
|
||||
|
||||
**CE — Chorus Extraction:** Identify repeated phrases, emotional peaks, or hook-quality lines (short, punchy, imagistic) and promote to `[Chorus]` at appropriate positions.
|
||||
|
||||
**CC — Chorus Creation:** Distill the poem's emotional core into a 2-4 line chorus with shorter lines than verses, built-in repetition, and vocabulary matching the voice profile if loaded. Place after first verse, repeat 2-3 times.
|
||||
|
||||
**Impact preview (CE/CC):** Show structural comparison (current stanzas vs. proposed sections with chorus placement) and character budget impact before applying.
|
||||
|
||||
**RA — Rhythmic Adjustment:** Produce lines with consistent syllable counts within each section (not across sections — inter-section variance may be intentional). Run `syllable-counter.py` on current draft.
|
||||
|
||||
Key RA knowledge:
|
||||
- WF mode: only break/combine lines, never substitute words
|
||||
- Punctuation shapes vocal delivery: commas = breath pauses, dashes = sharp breaks, ellipses = trailing. Use intentionally.
|
||||
- Flag high syllable density lines (polysyllabic word clusters) as singability concerns
|
||||
- In heavy/aggressive genres, flag `!` — triggers aggressive vocal attacks that bleed forward
|
||||
- Use line density variation between sections for tempo contrast
|
||||
- **Verification mandate:** Never claim rhythmic properties without `syllable-counter.py` output confirming them
|
||||
|
||||
**RE — Rhyme Enhancement:** Strengthen rhyme patterns using genre-appropriate schemes (AABB for energy, ABAB for narrative, ABCB for folk). WF mode: only suggest minor word swaps at line endings. Suno's vocal engine responds better to clear rhyme patterns.
|
||||
|
||||
**FR — Full Rewrite:** Rewrite entirely as song lyrics preserving theme, core imagery, and emotional arc. Match voice profile patterns. Explain creative choices.
|
||||
|
||||
**CD — Cliche Detection:** Run `cliche-detector.py`, suggest 2-3 genre-aware alternatives per flagged phrase. WF mode: flag only, don't auto-replace.
|
||||
|
||||
**Character budget check (after all transformations):** Break out: "Lyrics: X chars / Metatags: Y chars / Total: Z/3,000 quality budget (5,000 hard limit)." Flag sections to trim if approaching 3,000. Flag critical if over 5,000 (silent truncation).
|
||||
|
||||
### Step 4: Quality Check & Present
|
||||
|
||||
**Validation (parallel batch):**
|
||||
- `validate-lyrics.py` — metatag formatting, blank lines, style cue contamination, character budget
|
||||
- `syllable-counter.py --estimate-duration` — syllable balance and duration estimate (present as rough heuristic with caveats, not hard limit)
|
||||
- `section-length-checker.py` — section lengths vs. section-jobs expectations (supports `--genre prog` for relaxed constraints)
|
||||
|
||||
If RA was applied and no further changes made, reuse those syllable results. If writing with a band profile, verify voice pattern alignment (LLM judgment). Fix issues before presenting.
|
||||
|
||||
**Verification mandates:**
|
||||
- All assertions about syllable counts, durations, section lengths must be supported by script output
|
||||
- Suno-specific claims: use web search when available to verify against current docs; state uncertainty when search unavailable
|
||||
|
||||
**Output format:**
|
||||
```
|
||||
## Copy-Ready Lyrics (paste directly into Suno)
|
||||
|
||||
[Complete lyrics with metatags — nothing else in this block]
|
||||
|
||||
## Transformation Summary
|
||||
- Sections: {count} ({list})
|
||||
- Estimated duration: {duration}
|
||||
- Character budget: Lyrics {lyric_chars} + Metatags {tag_chars} = {total}/3,000 ({pct}%)
|
||||
- Transformations applied: {list}
|
||||
- Syllable range per line: {min}-{max} (target: {target})
|
||||
|
||||
## Changes Made
|
||||
{Key structural decisions — why chorus placed here, why this line was broken, etc.}
|
||||
|
||||
## Cliche Report (if CD applied)
|
||||
- {N} flagged, {M} replaced
|
||||
- Kept: {list if interactive}
|
||||
```
|
||||
|
||||
**Before/after diff:** Run `lyrics-diff.py` and `assemble-summary.py` in parallel. Present annotated diff showing which transformation code caused each change (enables selective undo).
|
||||
|
||||
**Refinement:** Offer 2-3 concrete suggestions based on quality data rather than open-ended questions. Loop back to relevant transformation step if changes requested. Offer side-by-side comparison with original.
|
||||
|
||||
**Headless mode:** Output Headless Output Contract JSON instead of formatted presentation.
|
||||
|
||||
### Step 5: Handoff Guidance
|
||||
|
||||
After user approval:
|
||||
- Remind: lyrics go into Suno's **lyrics input**, not the style prompt field
|
||||
- **Starter style prompt:** Generate a brief style prompt snippet from genre/mood/energy/vocal cues. Present as starting point for Style Prompt Builder or direct Suno use.
|
||||
- **Iteration tip:** "Generate 3-5 versions — Suno interprets the same lyrics differently each time."
|
||||
- Suggest Style Prompt Builder if they have a band profile
|
||||
- Note Feedback Elicitor availability for post-listen refinement (feeds back into Refinement Mode)
|
||||
- For multi-song projects, recommend establishing a band profile first
|
||||
- **Save to songbook (optional):** Save to `docs/songbook/{band-profile-or-untitled}/{song-title}.md` with frontmatter (source hash, transformations, date, version, profile, char count). Increment version for iterative refinement.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `validate-lyrics.py` | Structure, metatags, formatting, char budget, punctuation density |
|
||||
| `cliche-detector.py` | Cliche detection with categorized alternatives |
|
||||
| `syllable-counter.py` | Per-line syllable counts, rhythmic consistency, duration estimate |
|
||||
| `validate-options.py` | Transformation option mutual exclusion rules |
|
||||
| `section-length-checker.py` | Section lengths vs. section-jobs expected ranges |
|
||||
| `analyze-input.py` | Pre-analysis: structure, repeated phrases, rhyme pairs, char count |
|
||||
| `lyrics-diff.py` | Structured diff between original and transformed lyrics |
|
||||
| `assemble-summary.py` | Assembles Transformation Summary from script outputs |
|
||||
|
||||
All scripts support `--help`. Located in `./scripts/`.
|
||||
@@ -0,0 +1 @@
|
||||
type: skill
|
||||
66
.agent/skills/suno-lyric-transformer/references/README.md
Normal file
66
.agent/skills/suno-lyric-transformer/references/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Lyric Transformer
|
||||
|
||||
The Lyric Transformer converts poems, raw text, and rough lyrics into Suno-ready structured song lyrics with metatags, proper section architecture, and rhythmic consistency. It offers seven transformation options that users can mix and match based on how much creative control they want to retain — from lightweight structure tagging to full rewrites — plus a Word Fidelity mode for writers who want their exact words preserved. The skill enforces Suno's lyrics character limits (5,000 hard limit on v4.5+, ~3,000 quality budget), runs cliche detection by default (Suno's vocal engine amplifies cliches), and integrates with band profile writer voice data to maintain authentic voice.
|
||||
|
||||
## When to Use Directly vs. Through Mac
|
||||
|
||||
Use this skill directly when you have existing text (a poem, prose, rough lyrics) that needs to be transformed into Suno-ready format. Use Mac (the orchestrating agent) when lyric transformation is part of a full song-creation workflow that includes profile management, style prompt building, or feedback refinement.
|
||||
|
||||
## Transformation Options
|
||||
|
||||
| Code | Transformation | Description |
|
||||
|------|---------------|-------------|
|
||||
| **ST*** | Structure Tagging | Add section metatags (`[Verse]`, `[Chorus]`, etc.) and descriptor metatags |
|
||||
| **CE** | Chorus Extraction | Identify repeated/hook material and promote to chorus |
|
||||
| **CC*** | Chorus Creation | Write a new chorus derived from the poem's emotional core |
|
||||
| **RA*** | Rhythmic Adjustment | Normalize syllable counts for stable vocal phrasing |
|
||||
| **RE** | Rhyme Enhancement | Strengthen rhyme patterns for better Suno vocal delivery |
|
||||
| **FR** | Full Rewrite | Complete rewrite as song lyrics preserving theme and imagery |
|
||||
| **CD*** | Cliche Detection | Flag overused phrases and suggest genre-aware alternatives |
|
||||
| **WF** | Word Fidelity Mode | Use writer's exact words; only add structure (mutually exclusive with FR) |
|
||||
|
||||
*Asterisk indicates default recommendations for raw text input.*
|
||||
|
||||
### Headless Mode (`--headless` or `-H`)
|
||||
|
||||
- `--headless:analyze` — Analyze input only, return analysis JSON
|
||||
- `--headless:transform` — Full transformation with default options (ST + CC + RA + CD)
|
||||
- `--headless:refine` — Apply targeted adjustments from Feedback Elicitor's adjustment spec
|
||||
- `--headless` with text — Analyze + transform with balanced defaults
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `validate-lyrics.py` | Validates lyrics structure, metatags, formatting, and 3,000-char limit |
|
||||
| `cliche-detector.py` | Detects cliche phrases with categorized genre-aware alternatives |
|
||||
| `syllable-counter.py` | Counts syllables per line, analyzes rhythm, and estimates song duration |
|
||||
| `analyze-input.py` | Pre-analyzes raw text for existing structure, repeated phrases, and rhyme pairs |
|
||||
| `section-length-checker.py` | Checks section lengths against expected ranges from the section-jobs framework |
|
||||
| `lyrics-diff.py` | Produces annotated diff between original and transformed lyrics |
|
||||
| `validate-options.py` | Validates transformation option selections against mutual exclusion rules |
|
||||
| `assemble-summary.py` | Assembles the Transformation Summary block from script outputs |
|
||||
|
||||
## Example Invocation
|
||||
|
||||
```
|
||||
# Interactive
|
||||
"Transform this poem into a song for my midnight-echoes profile"
|
||||
"Convert my lyrics for Suno — just tag the structure, keep my words"
|
||||
|
||||
# Headless
|
||||
--headless:transform --text "poem text here" --options ST,CC,RA,CD --profile midnight-echoes
|
||||
--headless:refine --source-lyrics "current lyrics" --adjustments adjustments.json
|
||||
--headless:analyze --text "poem text here"
|
||||
```
|
||||
|
||||
## Key Constraints
|
||||
|
||||
- **5,000-character hard limit** (v4.5+), **~3,000-character quality budget** — beyond 3,000, Suno rushes sections; beyond 5,000, content is silently truncated
|
||||
- **FR and WF are mutually exclusive** — you cannot fully rewrite while preserving exact words
|
||||
- **CE is skipped when FR is selected** — full rewrite subsumes chorus extraction
|
||||
- Refinement mode accepts adjustment specs from the Feedback Elicitor for targeted changes
|
||||
|
||||
## Part of the Suno Band Manager Module
|
||||
|
||||
This skill is part of the Suno Band Manager module and works with any LLM CLI supporting the [Agent Skills](https://agentskills.io) standard. For the full guided experience, invoke Mac — the orchestrating agent — instead of using this skill directly.
|
||||
@@ -0,0 +1,954 @@
|
||||
# Suno Metatag Reference
|
||||
|
||||
Metatags are keywords in square brackets `[ ]` placed in the lyrics field to guide Suno's generation. This reference covers all known working tags as of April 2026. Suno evolves frequently — when uncertain about a tag's effectiveness, use web search to verify against current documentation.
|
||||
|
||||
> **Related references:** For how metatags interact with style prompts, see `suno-style-prompt-builder/references/model-prompt-strategies.md`. For mapping user feedback to metatag adjustments, see `suno-feedback-elicitor/references/suno-parameter-map.md`. For section emotional roles and poem-to-song mapping, see `section-jobs.md` (same directory).
|
||||
|
||||
**Confidence Levels:** Tags are marked HIGH (multiple sources confirm), MEDIUM/Experimental (1-2 sources, may not work consistently), or unmarked (established/proven). HIGH-confidence new additions from March 2026 research are integrated into existing sections. MEDIUM-confidence tags are marked with "(Experimental)" throughout.
|
||||
|
||||
## Section Structure Tags
|
||||
|
||||
Core tags that define song structure. Suno uses these to organize musical sections.
|
||||
|
||||
**CRITICAL: Only use recognized tags.** Custom/invented tags like `[The Questions]` or `[Reflection]` are NOT recognized by Suno. At best they are ignored; at worst **Suno sings the tag text as lyrics** ("The Questions" becomes a sung line). Always map sections to recognized tags and use parameterized syntax or descriptor tags to shape the musical feel.
|
||||
|
||||
**Section-tag content: direction, not narrative labels.** The space inside section tags — the text between `[` and `]` — is valuable real estate Suno can act on. Use it for **functional direction** (tempo, dynamics, vocal style, mood, energy) Suno can interpret, NOT for **human-readable narrative labels** Suno has no training on.
|
||||
|
||||
| Format | Effect |
|
||||
|--------|--------|
|
||||
| `[VERSE 1 — THE ROOM]` | BAD. Suno doesn't know what "— THE ROOM" means. At best ignored; at worst the phrase gets sung as lyrics. Burns character budget for nothing. |
|
||||
| `[Verse 1: hushed, tense]` | GOOD. Parameterized tag content — Suno interprets the arrangement/delivery cues. |
|
||||
| `[Breakdown — THE TURN]` | BAD. Same issue — descriptive narrative label has no generation signal. |
|
||||
| `[Breakdown: stripped, declarative]` | GOOD. Functional direction Suno can act on. |
|
||||
|
||||
When a source songbook uses em-dashed descriptive labels in section tags (common in longer-form catalog entries), translate them to Suno-actionable direction before pasting into the lyrics field. If a label like "— THE TURN" carries useful information (structural pivot, emotional shift), translate it to functional direction that captures the same intent: `[Breakdown: stripped, declarative]`. Keep human-readable commentary in songbook notes / frontmatter, not in the Suno-paste-ready lyrics block. Applies equally to cross-band conversions — the source band's human-readable labels should be cleaned up for the target band's lyrics block.
|
||||
|
||||
| Tag | Usage | Notes |
|
||||
|-----|-------|-------|
|
||||
| `[Intro]` | Instrumental or minimal vocal opening | Notoriously unreliable — keep short or omit |
|
||||
| `[Verse]` / `[Verse 1]` / `[Verse 2]` | Narrative/story sections | Number if multiple |
|
||||
| `[Pre-Chorus]` | Transitional build before chorus | Short — 2-4 lines, creates tension/lift toward chorus |
|
||||
| `[Chorus]` | Main hook/payoff section | Short repeated hooks > long novel choruses |
|
||||
| `[Post-Chorus]` | Section immediately after chorus | Extends chorus energy or provides cooldown. Genre-dependent: very effective in pop/EDM, may blend with chorus in rock/metal |
|
||||
| `[Bridge]` | Contrasting section — new harmonic content | Introduces NEW chords, melody, perspective. A bridge gives you something the song hasn't heard yet. Usually appears once |
|
||||
| `[Outro]` | Closing section | Fade, resolution, or final statement |
|
||||
| `[End]` | Hard stop | Use to signal a definitive ending |
|
||||
| `[Final Chorus]` | Last chorus iteration | Often bigger/louder than standard chorus |
|
||||
| `[Hook]` | Short catchy phrase | Distinct from chorus — can be a repeated motif |
|
||||
| `[Refrain]` | Repeated line or phrase | Simpler than a full chorus |
|
||||
| `[Instrumental Intro]` | Instrumental-only opening | More reliable than bare `[Intro]` for ensuring no vocals (HIGH) |
|
||||
| `[Instrumental Break]` | Explicit instrumental mid-song break | Clearer intent than `[Break]` alone (HIGH) |
|
||||
| `[Drum Break]` | Percussion-only break section | Strips everything except drums (HIGH) |
|
||||
| `[Percussion Break]` | Percussion-focused break | Similar to Drum Break but may include auxiliary percussion (HIGH) |
|
||||
| `[Build]` | Rising energy section | Shorthand for `[Build-Up]`; confirmed on v5 (HIGH) |
|
||||
| `[Big Finish]` | Grand climactic ending section | Signals a big, climactic ending (HIGH) |
|
||||
| `[Chorus x2]` | Repeat chorus twice | Chorus doubling without rewriting lyrics (HIGH) |
|
||||
|
||||
### [Bridge] vs [Breakdown] — Functional Distinction
|
||||
|
||||
These serve fundamentally different purposes:
|
||||
|
||||
- **[Bridge]** = **Something NEW.** New chords, new melody, potentially a different key. It repositions the song's narrative and emotional angle. Maintains or shifts energy but does NOT necessarily strip instrumentation. Use for narrative/emotional turns, contrasting perspectives, moments where the song needs to go somewhere it hasn't been.
|
||||
|
||||
- **[Breakdown]** = **Something LESS.** Subtractive arrangement — specifically strips instruments (typically drums and/or bass) while spotlighting vocals or a single motif. Use when you want the song to thin out, expose the vocal, create breathing room. In metal/metalcore context, forces a tempo drop and heavy rhythm (genre-aware behavior). Effective for creating maximum contrast before a high-energy section — the stripped-back breakdown makes the next section hit harder.
|
||||
|
||||
**Choosing between them:**
|
||||
- Song needs a new harmonic direction → `[Bridge]`
|
||||
- Song needs to strip down and spotlight the vocal → `[Breakdown]`
|
||||
- Song needs both (strip down AND new perspective) → `[Bridge | Half-Time]` + `[Energy: stripped, minimal]`
|
||||
|
||||
### [Pre-Chorus] and [Post-Chorus] — Distinct Musical Sections
|
||||
|
||||
Both create genuinely distinct musical moments, not just extensions of adjacent sections:
|
||||
|
||||
- **[Pre-Chorus]** creates a **tension/lift build** before the chorus. Suno adds percussion, harmony layers, increases vocal intensity. Without this tag, transitional lyrics before a chorus may be sung awkwardly as "an extra line that doesn't fit the meter." Adding the tag signals the break in pattern is intentional. Keep short — 2-4 lines.
|
||||
|
||||
- **[Post-Chorus]** creates an **extension or cooldown** after the chorus. Can manifest as a repeated chant, vocal chops, instrumental hook, or response line. Inherits the chorus's energy level but creates a different musical moment. Most effective in pop/EDM; in rock/metal may blend more closely with the chorus.
|
||||
|
||||
### [Interlude] — Transitional Palette Cleanser
|
||||
|
||||
Defaults to **instrumental** (listed under Instrumental & Solo Section Tags). If lyrics are placed below it, Suno will attempt to sing them but with lighter/transitional musical treatment. Creates a brief palate cleanser between major sections — neutralizes energy rather than dramatically shifting it. Chaining `[Interlude]` with `[Solo]` is effective for changing movement or overall tone.
|
||||
|
||||
### Mapping Non-Standard Sections to Recognized Tags
|
||||
|
||||
When a song has sections that aren't traditional verse/chorus/bridge (e.g., spoken word passages, interrogative sections, narrative asides), map them to the closest recognized tag and use parameterized syntax to shape the feel:
|
||||
|
||||
| Section Intent | Recommended Tag | Why |
|
||||
|---|---|---|
|
||||
| Interrogative/reflective passage | `[Breakdown: building intensity]` | Strips instrumentation, spotlights vocal, creates contrast with surrounding sections |
|
||||
| Spoken word passage | `[Verse X]` + `[Spoken Word]` | Verse structure with delivery override |
|
||||
| Energy reset between aggressive sections | `[Break]` or `[Breakdown]` | Creates silence/space to prevent energy bleed |
|
||||
| Closing passage that isn't a chorus | `[Outro]` | Suno treats as closing — appropriate energy wind-down |
|
||||
| Build toward climax | `[Pre-Chorus]` or `[Build]` | Creates tension/lift |
|
||||
| Repeated motif or chant | `[Post-Chorus]` or `[Hook]` | Inherits prior energy, repetition-friendly |
|
||||
|
||||
## Instrumental & Solo Section Tags
|
||||
|
||||
Tags that create instrumental moments with no lyrics. These add duration to the song beyond what lyric lines alone suggest.
|
||||
|
||||
| Tag | Usage | Typical Duration |
|
||||
|-----|-------|-----------------|
|
||||
| `[Instrumental]` | General instrumental section | 10-25 sec |
|
||||
| `[Interlude]` | Musical bridge between sections | 8-20 sec |
|
||||
| `[Solo]` | Generic instrumental solo | 10-25 sec |
|
||||
| `[Guitar Solo]` | Guitar-focused solo section | 10-25 sec |
|
||||
| `[Piano Solo]` | Piano-focused solo section | 10-25 sec |
|
||||
| `[Sax Solo]` / `[Saxophone Solo]` | Saxophone solo | 10-25 sec |
|
||||
| `[Drum Solo]` | Drum-focused solo section | 8-20 sec |
|
||||
| `[Bass Solo]` | Bass-focused solo section | 8-20 sec |
|
||||
| `[Break]` | Brief pause or stripped-back moment | 5-15 sec |
|
||||
| `[Breakdown]` | Stripped-back section, reduces energy | 8-20 sec |
|
||||
| `[Build-Up]` / `[Buildup]` | Rising energy, leads into a climax | 5-15 sec |
|
||||
| `[Drop]` | Sudden energy release (EDM/electronic) | 10-20 sec |
|
||||
| `[Synth Solo]` | Synthesizer solo section (HIGH) | 10-25 sec |
|
||||
| `[Violin Solo]` | Violin solo section (HIGH) | 10-25 sec |
|
||||
| `[Bass Drop]` | Sudden heavy bass entry, EDM style (HIGH) | 5-15 sec |
|
||||
| `[Strings Rise]` | Strings gradually build/swell (HIGH) | 8-20 sec |
|
||||
|
||||
## Vocal Delivery Tags
|
||||
|
||||
Control how Suno's vocal engine performs specific sections. Place right before the section tag or between the section tag and the first lyric line. Use one primary delivery cue per section — stacking reduces effectiveness.
|
||||
|
||||
**Three-layer vocal specification** (HookGenius technique) — for maximum vocal control, specify across three layers:
|
||||
1. **Character**: 'raspy female vocals', 'smooth baritone', 'deep female alto'
|
||||
2. **Delivery**: 'breathy', 'powerful belt', 'whispered', 'falsetto', 'aggressive'
|
||||
3. **Effects**: 'reverb-drenched', 'dry close-mic', 'doubled harmonies', 'lo-fi filtered'
|
||||
|
||||
'Just saying male vocals gives Suno no direction' — specificity across all three layers dramatically improves consistency.
|
||||
|
||||
**Vocal delivery reliability tiers** (HookGenius 300+ tag testing):
|
||||
- **HIGH**: `[Raspy]`, `[Breathy]`, `[Powerful]`, `[Spoken Word]`, `[Choir]`, gender tags
|
||||
- **MEDIUM**: `[Operatic]`, `[Whispered]` (reliable but reduces overall track energy), `[Melodic Rap]`, `[AutoTune]`, `[Harmonies]`
|
||||
- **LOW**: `[Falsetto]`, `[Growling]`, `[Yodeling]` (rarely produces actual yodeling)
|
||||
|
||||
### Volume & Intensity
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Whispered]` / `[Whisper]` | Soft, breathy, intimate delivery |
|
||||
| `[Soft]` / `[Gentle]` / `[Quiet]` | Subdued, low-volume singing |
|
||||
| `[Spoken]` / `[Spoken Word]` | Spoken rather than sung |
|
||||
| `[Powerful]` / `[Intense]` | Full-force, emotional delivery |
|
||||
| `[Belted]` / `[Belting]` | Powerful, full-voice, high-energy singing |
|
||||
| `[Shouted]` / `[Screamed]` | Aggressive, loud delivery |
|
||||
| `[Growled]` / `[Growl]` | Low, guttural vocal delivery |
|
||||
| `[Gritty]` | Gritty, rough vocal tone (HIGH) |
|
||||
| `[Monotone]` | Flat, monotone delivery (HIGH) |
|
||||
| `[Breathless]` | Breathless, urgent delivery (HIGH) |
|
||||
|
||||
### Vocal Style & Technique
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Falsetto]` / `[Head Voice]` | High, airy vocal register — **LOW reliability** (HookGenius testing: 'sometimes Suno delivers it, sometimes ignores it entirely'). Try 'natural falsetto, airy high register, effortless' in the style prompt instead for more consistent results. |
|
||||
| `[Chest Voice]` | Full, resonant lower register |
|
||||
| `[Breathy]` | Airy, breath-heavy vocal |
|
||||
| `[Raspy]` | Rough, textured vocal |
|
||||
| `[Smooth]` / `[Soulful]` | Polished, warm delivery |
|
||||
| `[Operatic]` | Classical vocal technique |
|
||||
| `[Crooning]` | Soft, intimate jazz-style singing |
|
||||
| `[Nasal]` | Nasal-toned delivery |
|
||||
| `[Airy]` | Light, ethereal vocal |
|
||||
| `[Harmonies]` / `[Harmonized]` | Multi-voice harmony layering |
|
||||
| `[Ad-libs]` / `[Ad-lib]` | Improvised vocal fills and runs |
|
||||
| `[Vocal Run]` / `[Melisma]` | Extended note runs across syllables |
|
||||
| `[Vibrato]` | Oscillating pitch on sustained notes |
|
||||
| `[Staccato]` | Short, detached vocal phrasing |
|
||||
| `[Legato]` | Smooth, connected vocal phrasing |
|
||||
| `[Call and Response]` | Back-and-forth vocal pattern |
|
||||
| `[Chant]` | Rhythmic, repetitive vocal pattern |
|
||||
| `[Choir]` / `[Choir Vocals]` | Full choir sound |
|
||||
| `[Scat]` | Improvised nonsense syllables (jazz) |
|
||||
| `[Hummed]` / `[Humming]` | Hummed melody, no words |
|
||||
| `[Whistled]` / `[Whistling]` | Whistled melody |
|
||||
| `[Backing Vocals]` | Explicit backing vocal layer (distinct from parentheses technique) (HIGH) |
|
||||
| `[Stacked Harmonies]` | Dense layered harmonies (HIGH) |
|
||||
| `[Gospel Choir]` | Gospel-style choir (HIGH) |
|
||||
| `[Narrator]` / `[Female Narrator]` | Narration voice, distinct from `[Spoken Word]` (HIGH) |
|
||||
| `[Announcer]` / `[Reporter]` | Announcer or reporter voice style (HIGH) |
|
||||
| `[Primal Scream]` | Raw, primal scream vocal (Experimental) |
|
||||
| `[Diva Solo]` | Big diva-style vocal moment (Experimental) |
|
||||
| `[Vocaloid]` | Vocaloid-style synthetic vocal (Experimental) |
|
||||
| `[Gregorian Chant]` | Gregorian chant style (Experimental) |
|
||||
| `[Androgynous Vocals]` | Gender-ambiguous voice (Experimental) |
|
||||
|
||||
### Rap & Hip-Hop Delivery
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Rapped]` / `[Rap]` | Rhythmic spoken delivery |
|
||||
| `[Fast Rap]` / `[Double Time]` | High-speed rap delivery |
|
||||
| `[Slow Flow]` | Deliberate, spaced-out rap |
|
||||
| `[Melodic Rap]` | Singing-rapping hybrid |
|
||||
| `[Trap Flow]` | Trap-style cadence with hi-hat patterns |
|
||||
| `[Boom Bap Flow]` | Classic hip-hop rhythmic delivery |
|
||||
| `[Mumble Rap]` | Mumbled, indistinct rap delivery (HIGH) |
|
||||
|
||||
### Vocal Identity
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Male Vocal]` / `[Male Vocalist]` / `[Man]` | Male voice |
|
||||
| `[Female Vocal]` / `[Female Vocalist]` / `[Woman]` | Female voice |
|
||||
| `[Boy]` / `[Girl]` | Younger-sounding voice |
|
||||
| `[Duet]` | Two distinct voices alternating |
|
||||
|
||||
### Vocal Effects
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Reverb]` | Reverberant vocal treatment |
|
||||
| `[Delay]` | Echo/delay on vocals |
|
||||
| `[AutoTune]` / `[No AutoTune]` | Add or prevent pitch correction |
|
||||
| `[Distorted Vocals]` | Distortion effect on voice |
|
||||
| `[Filtered Vocals]` | Filtered/muffled vocal sound |
|
||||
| `[Vocoder]` | Robotic/synthesized vocal effect |
|
||||
| `[Telephone Effect]` | Lo-fi phone-quality vocal |
|
||||
| `[Glitch]` | Glitch effect on vocals (Experimental) |
|
||||
|
||||
### Vocal Emotion
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Vulnerable]` | Fragile, exposed delivery |
|
||||
| `[Defiant]` | Strong, resistant tone |
|
||||
| `[Sultry]` | Sensual, low-energy seduction |
|
||||
| `[Joyful]` | Bright, happy delivery |
|
||||
| `[Melancholic]` | Sad, wistful tone |
|
||||
| `[Aggressive]` | Forceful, combative delivery |
|
||||
|
||||
## Descriptor Metatags
|
||||
|
||||
Provide guidance to Suno's interpretation. Keep text short: 1-3 words.
|
||||
|
||||
### Core Descriptor Tags (Established)
|
||||
| Tag | Example | Placement |
|
||||
|-----|---------|-----------|
|
||||
| `[Mood: ...]` | `[Mood: haunting]` | Top (global) or before section (local) |
|
||||
| `[Energy: ...]` | `[Energy: building]` | Before section |
|
||||
| `[Vocal Style: ...]` | `[Vocal Style: whispered]` | Before section |
|
||||
| `[Instrument: ...]` | `[Instrument: solo piano]` | Before section |
|
||||
|
||||
### Additional Descriptor Families (HIGH confidence — colon syntax)
|
||||
These follow the same `[Category: value]` pattern as the core descriptors above:
|
||||
|
||||
| Tag | Examples | Notes |
|
||||
|-----|---------|-------|
|
||||
| `[Atmosphere: ...]` | `[Atmosphere: Dreamy]`, `[Atmosphere: Cyberpunk]`, `[Atmosphere: Medieval]` | Sets environmental/spatial context |
|
||||
| `[Texture: ...]` | `[Texture: Grainy]`, `[Texture: Velvet]` | Controls sonic texture quality |
|
||||
| `[Effect: ...]` | `[Effect: Lo-fi]`, `[Effect: Reverb: Hall]`, `[Effect: Delay: Ping-pong]`, `[Effect: Distortion]`, `[Effect: Sidechain]`, `[Effect: Radio Filter]`, `[Effect: Bitcrusher]` (digital degradation/8-bit sound), `[Effect: Autopan]` (sound panning left to right), `[Effect: Sidechain]` (pumping volume effect, common in House) | Production effects — supports nested colon syntax for specificity |
|
||||
| `[Harmony: ...]` | `[Harmony: High]` | Harmony register/style guidance |
|
||||
| `[Voice: ...]` | `[Voice: Auto-tune]` | Vocal processing direction |
|
||||
| `[Vibe: ...]` | `[Vibe: Cinematic]` | Overall vibe/feel — similar to Mood but more production-oriented |
|
||||
| `[Tempo: ...]` | `[Tempo: slow]` | Tempo suggestion (note: BPM-specific tags remain ineffective — see Experimental Section Tags) |
|
||||
|
||||
### Standalone Mood Tags (bare bracket — no colon needed) (HIGH)
|
||||
These work as simple bracket tags without the `[Mood: ...]` prefix:
|
||||
|
||||
`[Uplifting]`, `[Haunting]`, `[Dark]`, `[Nostalgic]`, `[Somber]`, `[Romantic]`, `[Dreamy]`, `[Peaceful]`, `[Anxious]`, `[Euphoric]`, `[Mysterious]`, `[Playful]`, `[Epic]`, `[Intimate]`, `[Bittersweet]`, `[Triumphant]`
|
||||
|
||||
### Standalone Energy Tags (bare bracket — no colon needed) (HIGH)
|
||||
These work as simple bracket tags without the `[Energy: ...]` prefix:
|
||||
|
||||
`[High Energy]`, `[Medium Energy]`, `[Low Energy]`, `[Chill]`, `[Driving]`, `[Explosive]`, `[Building]`, `[Relaxed]`, `[Frantic]`, `[Steady]`
|
||||
|
||||
**Mood word effectiveness:** Vivid, visceral words work better than polite ones. `[Mood: Mardi Gras]`, `[Mood: wild, party]`, `[Mood: dark, haunting]` are more effective than `[Mood: festive]` or `[Mood: celebratory]`. Suno responds to emotional intensity in tag language.
|
||||
|
||||
### Energy Tags — Production-Confirmed Behavior
|
||||
These energy and vocal style descriptors have been tested in production and produce reliable results:
|
||||
|
||||
| Tag | Observed Effect |
|
||||
|-----|-----------------|
|
||||
| `[Energy: stripped, minimal]` | Reliably reduces instrumentation |
|
||||
| `[Energy: massive]` | Reliably adds full band weight |
|
||||
| `[Energy: building]` | Works for gradual intensity increase |
|
||||
| `[Vocal Style: whispered]` | More reliably quiet than `[Vocal Style: clean, distant]` — use as the go-to for quiet sections |
|
||||
| `[Vocal Style: acapella]` | Sometimes works, sometimes Suno adds light instrumentation anyway |
|
||||
| `[Whispered, vulnerable]` | Reliable quiet-section tag in folk-intimate / acoustic-singer-songwriter / ballad-intimate contexts. **Context-dependent caveat (April 2026):** In theatrical-horror / voodoo-rock / dramatic-narrative contexts, `[Whispered, vulnerable]` can pull Suno into spoken-word delivery rather than sung-quiet. Use `[Vocal Style: soft, sung]` when sung-quiet is required in those genres — the explicit `sung` token defeats spoken-word drift. |
|
||||
|
||||
### Three-Phase Dynamic Arcs (Up, Peak, Down)
|
||||
For songs that need to build UP and come back DOWN, place descent tags at the **transition point**, not just the outro. The mistake is saving all the quiet tags for `[Outro]` — by then the energy has already carried through. Instead:
|
||||
|
||||
1. Place `[Energy: minimal, fading to silence]` and `[Vocal Style: whispered, vulnerable]` **before** the final lines, at the moment the song should begin its descent.
|
||||
2. `[Whispered, vulnerable]` is reliable for quiet sections in folk-intimate / acoustic-singer-songwriter / ballad contexts. Prefer it over `[Soft]` or `[Gentle]` when you need a guaranteed drop — but see caveat: in theatrical-horror / voodoo-rock / dramatic-narrative territory, it can pull Suno into spoken-word delivery. Use `[Vocal Style: soft, sung]` there; the explicit `sung` token defeats spoken-word drift.
|
||||
3. The descent tag placement matters more than the outro tags. If the transition into the final section is already quiet, the outro follows naturally.
|
||||
|
||||
### Vocal Style Findings — Harmonized as Sweet Spot
|
||||
`[Vocal Style: gritty]` combined with high energy and high Weirdness produces screaming even with Exclude Styles set to block it. `[Vocal Style: clean]` removes too much edge — it strips the character out of the vocals. **`[Vocal Style: harmonized]` on all sections is the sweet spot for dual-vocalist work** — it blends both voices naturally without pushing into scream territory or losing grit. "Raw gritty melodic singing" in the style prompt works fine when paired with `[Vocal Style: harmonized]` in the metatags — the style prompt provides the tonal character while the metatag controls the delivery mode.
|
||||
|
||||
### Structural Metaphor via Time Signature Changes
|
||||
Using different time signatures for different section types creates structural metaphor where musical form embodies lyrical meaning. For example: odd time signatures for verses (chaos, instability) paired with straight 4/4 for choruses (resolution, arrival). This is a powerful technique for prog — the musical structure itself becomes a storytelling device. Implement via experimental time signature tags (e.g., `[Verse 1: 7/8]`, `[Chorus: 4/4]`), acknowledging these are inconsistently respected but worth attempting for the payoff when they land. Note: BPM tags are confirmed ineffective (see Experimental Section Tags), but time signature tags are a separate mechanism worth trying.
|
||||
|
||||
### Dual Vocals — What Works and What Doesn't (updated 2026-04-09 with community research)
|
||||
|
||||
**Bottom line:** There is no fully reliable method in Suno v5/v5.5 to produce two genuinely distinct male voices trading lines in a single generation. Community consensus (Jack Righteous, Suno.wiki, HookGenius, Suno Architect) describes duets as "more of an exploit than a feature." **Same-gender male-male dual voicing is the hardest case** — nearly all working duet techniques rely on male/female gender contrast because gender is the strongest vocal signal the model respects.
|
||||
|
||||
**What DOES work reliably:**
|
||||
- `dual male vocals harmonized and gritty` in the style prompt produces harmony/doubling on choruses (NOT distinct voices trading — same voice doubled or harmonizing)
|
||||
- `[Male]` / `[Female]` per-line — the only reliable duet technique, requires gender contrast
|
||||
- `[Clean Vocal]` / `[Harsh Vocal]` — works in metalcore/deathcore/post-hardcore context, produces clean-vs-screaming contrast (not clean-vs-manic-speaking)
|
||||
|
||||
**What does NOT work:**
|
||||
- `[Voice 1]` / `[Voice 2]` — numbering is ignored
|
||||
- `[Male Vocal 1]` / `[Male Vocal 2]` — same-gender numbering ignored
|
||||
- `[Lead Vocal]` / `[Response Vocal]` — ignored
|
||||
- `[Duet]` alone — unreliable, voices swap roles or collapse into one timbre
|
||||
- `dual vocals trading` in style prompt — does not produce trading
|
||||
- Same-gender named characters (`[Lazarus]` / `[Mongoose]`) — inconsistent
|
||||
- Persona + dual voices — Persona is designed for single-voice consistency, actively fights against vocal variation
|
||||
- Describing two equal vocalists in style prompt — model averages conflicting descriptors into one voice
|
||||
|
||||
**Workarounds ranked by reliability (for same-gender dual-voice needs):**
|
||||
|
||||
1. **Multi-stage Studio Replace Section workflow** (HIGH reliability) — Persona OFF. Generate base track with main voice only. Use Replace Section on each intrusive voice section with a completely different style prompt (different vocal character descriptors, different delivery tags). Iterate section-by-section. Slow but actually works.
|
||||
|
||||
2. **Nu-metal/rapcore hybrid framing** (MEDIUM reliability, best aesthetic match for "manic/unhinged" characters) — Frame as "experimental nu-metal with rapid-fire manic spoken interjections" or invoke Mr. Bungle / System of a Down / Mike Patton / Serj Tankian territory. Rap-feature contexts tolerate vocal role-shifting better than straight metal. Model has training data of rapid vocal-character shifts in these genres.
|
||||
|
||||
3. **Metalcore clean/harsh framing** (MEDIUM-HIGH reliability, but produces scream not manic) — `[Clean Vocal]` main lines + `[Harsh Vocal]` or `[Shouted]` interjections. Reliably produces contrast, but the harsh voice comes out aggressive/screamed rather than gleeful/unhinged.
|
||||
|
||||
4. **Lead + Adlibs pattern** (MEDIUM reliability) — Main voice dominant, intrusive voice as sparse 3-6 word interjections maximum. Use `[adlibs: higher pitched spoken, manic]` inline before interjections. Keep sections to 8-12 lines max. Best fallback when the model keeps collapsing to one timbre.
|
||||
|
||||
5. **Separate generations + DAW stitch** (HIGH reliability, external tools) — Generate two full versions (one all-main, one all-intrusive) with different style prompts, then stitch sections manually in a DAW or via Extend.
|
||||
|
||||
**Parenthetical backing vocals for dual-voice effect:** Parentheses work as backing vocals reliably in pop/R&B/soul/gospel/hip-hop contexts. In thrash/metal contexts they come in as whispered phrases or ambience rather than true second-voice backing — NOT suitable for rapid intrusive-voice dialogue in those genres.
|
||||
|
||||
**Key prerequisite for all dual-voice attempts: Persona OFF.** Personas lock vocal character by design. Band profiles that use a Persona for their main sound must drop it for dual-voice songs and rebuild the sound character in the style prompt.
|
||||
|
||||
## Dynamic & Transition Tags
|
||||
|
||||
Tags that control energy flow and transitions within the song.
|
||||
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Fade In]` | Gradual volume increase at start |
|
||||
| `[Fade Out]` / `[Fade]` | Gradual volume decrease |
|
||||
| `[Swell]` | Gradual intensity increase |
|
||||
| `[Crescendo]` | Building volume/intensity |
|
||||
| `[Decrescendo]` | Decreasing volume/intensity |
|
||||
| `[Silence]` | Brief moment of silence |
|
||||
| `[Stop]` | **WARNING: Suno VOCALIZES this tag** — sings/yells the word "Stop" instead of treating it as a stop instruction. DO NOT use for ending control. |
|
||||
| `[End]` | Hard stop — prevents trailing instrumental generation after lyrics. Most reliable single ending tag, but may still produce 5-15 seconds of trailing instrumental. |
|
||||
| `[Soft End]` | Gentle ending variation (HIGH) |
|
||||
| `[Dramatic End]` | Dramatic ending variation (HIGH). Production testing (2026-04): did NOT produce abrupt endings on thrash/metal — still trailed instrumental. |
|
||||
| `[Big Finish]` | Grand climactic ending (HIGH) — also works as a section tag |
|
||||
| `[Instrumental End]` | Finish with instrumentation only, no vocals (HIGH) |
|
||||
| `[Slow Fade Out]` | Longer, gentler fade — best for ambient/cinematic (HIGH) |
|
||||
| `[Fast Fade Out]` | Quick fade — best for dance/shortform (HIGH) |
|
||||
| `[Instrumental Fade Out]` | Vocals end, instruments continue briefly then fade (HIGH) |
|
||||
| `[Cinematic Fade Out]` | Strings/pads fade first, rhythm fades last (HIGH) |
|
||||
| `[Unresolved tension]` | Avoids tonic resolution, ends on suspended chord (HIGH) |
|
||||
| `[Key Change]` / `[Key Modulation]` | Signal a key change, usually upward for a lift (HIGH) |
|
||||
| `[Metric Modulation]` | Rhythmic shift changing perceived tempo (HIGH) |
|
||||
| `[Accelerando]` | Gradually speed up tempo (HIGH) |
|
||||
| `[Ritardando]` | Gradually slow down tempo (HIGH) |
|
||||
|
||||
### Ending Control — Practical Strategies (2026-04 production testing)
|
||||
|
||||
Suno's ending behavior is one of its **least controllable** aspects. No tag combination reliably produces a clean stop immediately after vocals. Strategies ranked by effectiveness:
|
||||
|
||||
1. **Crop/trim in the editor** — most reliable. Let Suno generate, then cut at the desired point. Apply a short fade if no natural stopping point exists. This is the recommended approach for precise endings.
|
||||
2. **Remove `[Outro]` tag entirely** — `[Outro]` tells Suno "this is a conclusion section, play it out" which generates long instrumental tails. Using `[Final Verse]` instead avoids triggering conclusion behavior and produces shorter tails.
|
||||
3. **`[Final Verse]` + `[Unresolved tension]` + `[End]`** — avoids conclusion behavior, avoids tonic resolution (less incentive for Suno to add resolving coda), hard stop. Best combo found in testing.
|
||||
4. **"abrupt ending" in style prompt** — small effect but stacks with structural changes. More effective in genres that naturally have short endings (punk, hardcore).
|
||||
5. **`[Fade Out]` + `[End]` combo** — documented as "more reliable stop signal than `[End]` alone" but in testing still produced 14 seconds trailing on a thrash track.
|
||||
6. **Replace Section on the ending** — regenerate just the tail. Multiple attempts may produce shorter endings stochastically.
|
||||
|
||||
**What does NOT work:**
|
||||
- `[Stop]` — Suno vocalizes it as a lyric
|
||||
- `[Dramatic End]` — does not produce abrupt endings (tested on thrash/metal)
|
||||
- Stacking/doubling `[End]` tags — treated same as single `[End]`
|
||||
- `[Outro: fading, sparse]` — may actively encourage MORE instrumental by signaling conclusion mode
|
||||
|
||||
**Grid-loss warning:** When using `[Accelerando]` or `[Ritardando]`, the AI can lose the rhythmic grid for the remainder of the track. Always provide a 'return to home' command — if you speed up for a Bridge, make the first line of your final Chorus or Outro include a stabilizing tag like `[Tempo: 120 BPM]` or a strong structural tag like `[Chorus]` to force recalibration. BPM tags are normally ineffective for setting tempo, but may serve as 'recalibration anchors' after dynamic tempo disruptions — this warrants further testing.
|
||||
|
||||
## Sound Effect Tags
|
||||
|
||||
**CRITICAL: Sound effects are the LEAST reliable category of metatags.** Multiple sources confirm they "don't work at all, or only work partially, and might play in an unexpected part of a song." Plan for post-production rather than relying on in-lyrics effects.
|
||||
|
||||
**Bracket tags near lyrics may be interpreted as VOCAL PROCESSING, not standalone sounds.** `[Static]` placed before a lyric line may apply a static/distortion effect to the vocals rather than producing actual static noise. Tags like `[Distorted Vocals]`, `[Filtered Vocals]`, `[Telephone Effect]` are explicitly vocal effects; environmental tags like `[Static]`, `[Rain]` occupy an ambiguous zone where Suno may treat them as either ambient sounds or vocal treatments depending on context.
|
||||
|
||||
### Reliability Tiers
|
||||
|
||||
**HIGH — Training-data-derived tags** (appear in real lyric transcriptions from Genius/AZLyrics):
|
||||
- `[bleep]` / `[Censored]` — bleep/censor sound
|
||||
- `[phone ringing]` — phone ring
|
||||
- `[gunshots]` — gunshot sounds
|
||||
- `[spoken word]` — switches to spoken delivery
|
||||
|
||||
These work because Suno's model learned them from actual song transcriptions.
|
||||
|
||||
**LOW — Environmental/ambient tags** (listed in guides but inconsistently recognized):
|
||||
|
||||
| Tag | Examples |
|
||||
|-----|---------|
|
||||
| **Nature** | `[Rain]`, `[Thunder]`, `[Wind]`, `[Ocean Waves]`, `[Birds Chirping]`, `[Forest]` |
|
||||
| **Urban** | `[City Ambience]`, `[Phone Ringing]`, `[Beeping]`, `[Static]` |
|
||||
| **Human** | `[Applause]`, `[Cheering]`, `[Clapping]`, `[Chuckles]`, `[Giggles]`, `[Sighs]`, `[Screams]`, `[Cough]`, `[Clears Throat]` |
|
||||
| **Music** | `[Record Scratch]`, `[Bell Dings]`, `[Fire Crackling]` |
|
||||
| **Animals** | `[Barking]`, `[Squawking]`, `[Howling]` |
|
||||
|
||||
**Best results:** `[Applause]` at the end of live-sounding tracks, `[Birds Chirping]` at intros for morning ambiance. Most others are unreliable.
|
||||
|
||||
### Asterisk Inline Sound Effects
|
||||
|
||||
`*text*` cues are intended for background atmospheric layering, distinct from bracket tags. In practice, Suno may interpret them as percussion/rhythmic patterns rather than true ambient sounds (e.g., `*machinegun fire*` may produce rapid rim-shots rather than actual gunfire sound).
|
||||
|
||||
Confirmed working examples (atmosphere, not percussion):
|
||||
- `*rainfall*`, `*wind sounds*`, `*ocean waves*`, `*vinyl crackle*`
|
||||
- `*distant thunder*`, `*soft whispers*`, `*crowd cheering*`, `*cafe ambience*`
|
||||
|
||||
**Hybrid notation** `(*effect*)` — parentheses wrapping asterisks — may be more reliable for getting actual sound textures when bracket or asterisk notation alone fails.
|
||||
|
||||
**Limitations:** Overuse clutters tracks; effects may overpower vocals; results are unpredictable; effects may map to percussion/drum patterns rather than ambient sounds. Use sparingly and plan for post-production.
|
||||
|
||||
**Note:** This is the ONE exception to the 'no asterisks in lyrics' rule documented elsewhere.
|
||||
|
||||
### Reliable Alternatives to In-Lyrics Sound Effects
|
||||
|
||||
1. **Style prompt descriptors** — describe the atmospheric intent in the style prompt ("mechanical, industrial atmosphere") rather than using in-lyrics effect tags
|
||||
2. **Suno Sounds** (beta) — separate Suno feature for generating standalone sound effects, instrument samples, and ambient clips as separate audio files. Layer in a DAW.
|
||||
3. **Post-production** — generate the song cleanly, then add effects in a DAW. This is the most reliable approach for specific sound design.
|
||||
4. **Stems extraction** (Pro/Premier) — separate into up to 12 stems, add effects to individual stems externally
|
||||
|
||||
Source: [Suno AI Sound Effects with Asterisks — Jack Righteous](https://jackrighteous.com/en-us/blogs/guides-using-suno-ai-music-creation/suno-ai-sound-effects-asterisks)
|
||||
|
||||
## Production & Mix Tags (HIGH)
|
||||
|
||||
Tags that control production quality and mix effects. Place before sections or at top for global effect.
|
||||
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Lo-fi]` | Lo-fi production quality |
|
||||
| `[Reverb Tail]` | Extended reverb decay effect |
|
||||
| `[Echo]` | Echo effect |
|
||||
| `[Vinyl Crackle]` / `[Vinyl Hiss]` | Vinyl texture overlay |
|
||||
| `[Distant Voices]` | Distant/far-away vocal texture |
|
||||
|
||||
## Timing & Rhythm Tags (HIGH)
|
||||
|
||||
Tags that control rhythmic feel and timing within sections. These are distinct from BPM tags (which remain ineffective — see Experimental Section Tags). These tags describe rhythmic patterns and feels that Suno can interpret.
|
||||
|
||||
| Tag | Effect |
|
||||
|-----|--------|
|
||||
| `[Half-Time]` | Half-time feel — slower, heavier beat |
|
||||
| `[Swung Feel]` / `[Shuffle]` | Swing/shuffle rhythm |
|
||||
| `[Triplet Feel]` | Triplet-based rhythmic feel |
|
||||
| `[Syncopated]` | Syncopated rhythm |
|
||||
| `[Straight]` | Straight (non-swung) rhythm |
|
||||
| `[Four on the Floor]` | Steady kick on every beat |
|
||||
| `[Polyrhythmic]` | Multiple simultaneous rhythms |
|
||||
| `[Breakbeat]` | Breakbeat rhythm pattern |
|
||||
|
||||
**Rhythm nouns over tempo adjectives:** "Halftime," "double-time," "shuffle," "breakbeat" lock rhythmic feel better than "slow," "fast," "upbeat." These nouns describe specific drum patterns Suno can interpret; adjectives are vague and often ignored.
|
||||
|
||||
## Standalone Instrument Tags (HIGH)
|
||||
|
||||
These work as bare bracket tags in the lyrics field — not just via `[Instrument: ...]` colon syntax. Place before a section to feature that instrument, or use as section tags for solos/features.
|
||||
|
||||
### Keys
|
||||
`[Piano]`, `[Electric Piano]`, `[Rhodes]`, `[Wurlitzer]`, `[Organ]`, `[Hammond Organ]`, `[Harpsichord]`, `[Clavinet]`, `[Mellotron]`
|
||||
|
||||
### Synths
|
||||
`[Synth]`, `[Analog Synth]`, `[Moog Synth]`, `[Synth Pad]`, `[Lead Synth]`, `[Synth Stabs]`, `[Pad]`, `[Pluck Synth]`, `[Arpeggiated Synth]`, `[Synth Bass]`, `[Acid Bass]`, `[Supersaw]`, `[Wobbly Bass]`
|
||||
|
||||
### Strings
|
||||
`[Acoustic Guitar]`, `[Electric Guitar]`, `[Distorted Guitar]`, `[Clean Guitar]`, `[Jangly Guitar]`, `[Fingerpicked Guitar]`, `[Slide Guitar]`, `[12-String Guitar]`, `[Classical Guitar]`, `[Bass Guitar]`, `[Slap Bass]`, `[Upright Bass]`, `[Fretless Bass]`, `[Violin]`, `[Viola]`, `[Strings]`, `[String Quartet]`, `[String Section]`, `[Cello]`, `[Double Bass]`, `[Pizzicato]`, `[Harp]`, `[Ukulele]`, `[Banjo]`, `[Mandolin]`, `[Sitar]`
|
||||
|
||||
### Brass & Winds
|
||||
`[Saxophone]`, `[Tenor Sax]`, `[Alto Sax]`, `[Trumpet]`, `[Trombone]`, `[French Horn]`, `[Tuba]`, `[Brass Section]`, `[Flute]`, `[Clarinet]`, `[Oboe]`, `[Harmonica]`, `[Accordion]`, `[Bagpipes]`, `[Didgeridoo]`
|
||||
|
||||
### Percussion
|
||||
`[Drums]`, `[Acoustic Drums]`, `[Electronic Drums]`, `[Brushed Drums]`, `[Live Drums]`, `[808s]`, `[808 Bass]`, `[808 Drums]`, `[Drum Machine]`, `[TR-909]`, `[Trap Hi-Hats]`, `[Taiko Drums]`, `[Congas]`, `[Bongos]`, `[Tambourine]`, `[Shaker]`, `[Handclaps]`, `[Claps]`, `[Gong]`, `[Timpani]`, `[Cinematic Percussion]`
|
||||
|
||||
### Orchestral
|
||||
`[Orchestra]`, `[Full Orchestra]`, `[Chamber Orchestra]`, `[Brass Stabs]`
|
||||
|
||||
## Per-Section Instrument Control
|
||||
|
||||
Suno does NOT support per-section instrument exclusion — there is no `[No Brass]` or `[Instrument: exclude X]` tag. The Exclude Styles field is global and inconsistent for instrument exclusion. Instead, use these strategies:
|
||||
|
||||
### Strategy 1: Positive Instrument Filling
|
||||
Tell Suno what instruments a section SHOULD have — this fills the "instrument attention" and crowds out unwanted elements:
|
||||
```
|
||||
[Verse 3]
|
||||
[Instrument: heavy distorted guitar, crushing bass]
|
||||
```
|
||||
By specifying the instruments you want, there's less room for unwanted instruments to creep in.
|
||||
|
||||
### Strategy 2: Style Prompt Instrument Ordering
|
||||
Place instruments you want throughout the song in the first ~200 characters of the style prompt. Place instruments you only want in specific sections (e.g., "NOLA funk brass") at the very END of the prompt — later content has less global influence, so it's more likely to appear only where metatags reinforce it.
|
||||
|
||||
### Strategy 3: Section-Specific Generation (Pro/Premier)
|
||||
Use the Legacy Editor (Pro) or Studio (Premier) to generate different sections separately with different style prompts. For example:
|
||||
- Generate verses with a style prompt that has NO brass references
|
||||
- Generate the outro/finale with brass in the style prompt
|
||||
- Splice together using the editor
|
||||
|
||||
### Strategy 4: Reinforce with Energy + Instrument Tags Together
|
||||
Pair `[Instrument: ...]` with `[Energy: ...]` tags for stronger section differentiation:
|
||||
```
|
||||
[Verse 3]
|
||||
[Energy: building]
|
||||
[Instrument: distorted guitar, pounding drums]
|
||||
|
||||
[Outro]
|
||||
[Energy: celebratory]
|
||||
[Instrument: brass section, funk bass, horns]
|
||||
```
|
||||
|
||||
### Key Limitation
|
||||
Even with these strategies, Suno's instrument control is probabilistic — the style prompt sets a global palette, and section-level tags nudge within that palette. For dramatic instrument changes between sections, section-by-section generation (Strategy 3) is the most reliable approach.
|
||||
|
||||
### The Stems Solution (Pro/Premier)
|
||||
|
||||
Per-section instrument control via prompting alone is unreliable. The most reliable workflow for songs requiring different instruments in different sections:
|
||||
|
||||
1. **Generate** with ALL desired instruments in the style prompt (accepting that they'll bleed into all sections)
|
||||
2. **Extract stems** — Suno Pro splits into up to 12 stems: vocals, backing vocals, drums, bass, guitar, keys, strings, **brass**, woodwinds, percussion, synth, FX
|
||||
3. **Edit in a DAW** (e.g., Audacity) — mute/remove unwanted instrument stems per section
|
||||
4. **Export** the final mix
|
||||
|
||||
Brass separates well as a dedicated stem. This is the recommended approach for songs with section-specific instrumentation.
|
||||
|
||||
**Important:** External DAW editing is a one-way operation. Once you edit outside Suno, you lose Suno's editing capabilities (Replace Section, Extend, etc.) on that version. Plan your Suno edits BEFORE exporting to a DAW.
|
||||
|
||||
## Parameterized Section Tags (HIGH — MAJOR v5 Feature)
|
||||
|
||||
Section tags support inline arrangement instructions via colon (`:`) or pipe (`|`) syntax. This allows per-section arrangement control directly in the section tag itself, without needing separate descriptor tags.
|
||||
|
||||
### Colon Syntax — Arrangement Instructions
|
||||
```
|
||||
[Verse: whispered vocals, acoustic guitar only]
|
||||
[Chorus: full band, powerful vocals]
|
||||
[Bridge: stripped back, piano only]
|
||||
[Verse 2: lo-fi, distant vocals, minimal drums]
|
||||
```
|
||||
|
||||
### Pipe Syntax — Rhythmic/Feel Modifiers
|
||||
```
|
||||
[Chorus | Half-Time]
|
||||
[Chorus | Double-Time]
|
||||
[Verse 3 | Swung Feel]
|
||||
```
|
||||
|
||||
Both syntaxes are confirmed working on v5. The colon syntax is more flexible (accepts comma-separated arrangement descriptions), while the pipe syntax is cleaner for single modifiers. These can be combined with separate descriptor tags on subsequent lines for maximum control, but the inline approach is often sufficient and saves character budget.
|
||||
|
||||
**Relationship to BPM tags:** Note that `[Verse 1: 65 BPM]` style BPM parameterization remains ineffective (see Experimental Section Tags below). The parameterized syntax works for arrangement/feel instructions, not for tempo numbers.
|
||||
|
||||
## Experimental Section Tags
|
||||
|
||||
These are partially supported and may not work consistently across all models.
|
||||
|
||||
| Tag Syntax | Purpose | Notes |
|
||||
|-----------|---------|-------|
|
||||
| `[Verse 1: 7/8]` / `[Chorus: 4/4]` | Time signature hint per section | Inconsistently respected but worth attempting for prog/experimental work. Studio 1.2's time signature picker does NOT yet send to generative models — in-lyric tags are currently the only way to attempt this |
|
||||
| `[Callback: ...]` | During Extend/Replace, references a prior section's feel | HIGH reliability for Extend/Replace workflows — 'Callback phrasing is respected reliably across Extend chains' (community-validated). Experimental for standard generation. e.g., `[Callback: Verse 1 energy]` — useful for maintaining continuity across generations |
|
||||
|
||||
### BPM Tags — Confirmed Ineffective
|
||||
|
||||
**BPM tags in lyrics have ZERO detectable effect on Suno's actual output.** This was tested across 5 songs with librosa analysis:
|
||||
- "Want" tagged at 60 BPM throughout — Suno delivered 95.7 BPM
|
||||
- "Back Woods" tagged 65-150 BPM across sections — Suno delivered 123 BPM steady, no variation
|
||||
|
||||
Tags like `[Verse: 65 BPM]` or `[Chorus: 130 BPM]` are ignored by the generative model. Suno picks its own tempo based on genre, style prompt, and arrangement context. **Do not use BPM tags in lyrics — they waste character budget and create false expectations.**
|
||||
|
||||
For actual tempo/pacing control, see "Line Density as Tempo Control" and "Half-Time / Double-Time Drum Feel" below.
|
||||
|
||||
## Tags Confirmed NOT Working
|
||||
|
||||
These tags are commonly recommended online but have been tested and found to have no reliable effect on Suno's output:
|
||||
|
||||
| Tag | Finding | Source |
|
||||
|-----|---------|--------|
|
||||
| BPM tags (`[Verse: 65 BPM]`) | Zero effect on output — confirmed by librosa analysis | Production testing |
|
||||
| `[Bilingual]` / `[Spanglish]` | Placeholders with no evidence of special model behavior | Community testing |
|
||||
| `[Live Version]` | Not reliably parsed; may subtly influence mixing but no strong evidence | Community testing |
|
||||
| `[Mono]` / `[Wide Stereo]` | Subtle and inconsistent — Suno v5 does not reliably obey them | Community testing |
|
||||
| `[Clean Lyrics]` / `[Explicit]` | Do not override the content filter | Community testing |
|
||||
| `[Key Change]` (for precise control) | May nudge toward modulation but does NOT guarantee a specific key change — for precise transposition, export to a DAW | Community testing |
|
||||
| Time signature tags in lyrics | Inconsistently respected; Studio 1.2 picker also not sent to generative models | Production + official docs |
|
||||
|
||||
## Lyric Formatting as Suno Controls
|
||||
|
||||
These are NOT metatags but critical formatting techniques that directly control Suno's vocal and rhythmic interpretation.
|
||||
|
||||
### Punctuation Effects
|
||||
| Character | Effect | Guidance |
|
||||
|-----------|--------|----------|
|
||||
| `,` (comma) | Breath pause | Use to shape natural phrasing |
|
||||
| `—` / `--` (dash) | Hard pause / extended syllable linkage | Creates a harder pause than comma or ellipsis |
|
||||
| `...` (ellipsis) | Micro-pause / trailing delivery | Suggests trailing off — more subtle than a dash |
|
||||
| `!` (exclamation) | **BARK/ATTACK TRIGGER** | Tells Suno's vocal engine to attack/bark that word. Bleeds forward into subsequent sections. **NEVER use in sections that should be clean/quiet.** Use sparingly even in aggressive sections. Avoid in metal context — bleeds forward aggressively. |
|
||||
| `?` (question mark) | Interrogative delivery | Generally respected — Suno lifts intonation at the end |
|
||||
| No punctuation | Suno decides phrasing | Can be useful for intentional ambiguity — let the model choose |
|
||||
|
||||
### Capitalization Effects
|
||||
| Style | Effect | Guidance |
|
||||
|-------|--------|----------|
|
||||
| Sentence case | Normal delivery | Use throughout as baseline |
|
||||
| ALL CAPS | **Loudness ceiling** | Confirmed: ALL CAPS words are sung with more passion/volume. If you cap words in Verse 1, you've already hit the ceiling — nowhere to build. Save caps for the absolute peak moment only (one word, one line, in the climax). |
|
||||
|
||||
### Stretched Words — Phonetic Disambiguation
|
||||
|
||||
When stretching a word with hyphenated letters for dramatic effect (e.g., `to-o-o-lling`), check whether the repeated vowel could collapse into a different word in Suno's vocal interpretation. If so, add a consonant or alt-vowel spelling to anchor the intended sound.
|
||||
|
||||
**Example — broken and fixed (Distant Mourning LV, April 2026):**
|
||||
- Broken: `to-o-o-lling` → Suno reads as "tooling" (the `to-o-o` collapses to "too" and lands on the more common nearby word)
|
||||
- Fixed: `toh-o-o-lling` → Suno reads as "tolling" (the `h` forces the "OH" vowel rather than "OO")
|
||||
- Result: `12 times tooling` became `12 times tolling` — intended word preserved through the stretch
|
||||
|
||||
**Why it happens:** Suno's vocal engine collapses repeated vowels into the simpler phoneme, and phonetically-ambiguous stretches drift to the closest common word in the engine's training data. Adding a consonant after the first vowel breaks the collapse and pins the intended sound.
|
||||
|
||||
**Disambiguation techniques:**
|
||||
- **Insert `h`:** `toh-o-o-lling`, `moh-oh-oh-rning`, `loh-oh-oh-st`
|
||||
- **Alt-vowel spelling:** `dy-eye-ing` instead of `dy-iii-ing`, `sigh-igh-ed` instead of `si-ii-ed`
|
||||
- **Double-consonant anchor:** `roll-l-l-ling` emphasizes the `ll`, harder to collapse
|
||||
- **Re-articulate the word:** `tolling... tolling... tolling` (ellipses + repetition) instead of elongation notation — often cleaner than stretching one word
|
||||
|
||||
**How to apply:** Before committing any hyphenated stretched-word in lyrics, run the collapse test mentally — *if this word gets sung as a long vowel, what word would Suno's engine settle on?* If the answer differs from the intended word, add phonetic disambiguation. Same applies when transforming poetry that has visual word-stretching conventions — the visual meaning may not survive vocal interpretation without phonetic anchors.
|
||||
|
||||
### Parentheses
|
||||
| Format | Effect |
|
||||
|--------|--------|
|
||||
| `(words in parentheses)` | Interpreted as **backing vocals/texture**, not lead melody. Useful for dual vocal interplay: lead line with (backing harmonies). |
|
||||
|
||||
**Parenthetical Backing Vocals — Production-Tested Details:**
|
||||
- **Space before the opening paren is catalog-standard: `word (echo)` not `word(echo)`.** A prior version of this doc recommended no-space ("tightens coupling"); that was based on a single-song experimental finding (SF Distant Mourning, March 2026) that got mis-promoted to a general rule. Verified across the LV catalog April 2026 — every song with working parenthetical backing vocals uses spaces before the paren. The no-space form caused `(blasting)` to be skipped entirely on DM-LV Bridge across multiple gens until spaces were added.
|
||||
- **Paren must be at END of line.** Mid-line parens — parens with text after the closing paren on the same line — are dropped inconsistently. If the sentence continues past the paren, break the line after the closing paren and put the continuation on a new line. Example broken-and-fixed (Distant Mourning LV, April 2026):
|
||||
```
|
||||
Broken (mid-line, "(blasting)" dropped across gens):
|
||||
The neverending (blasting) Sound of the Bell
|
||||
|
||||
Fixed (paren at end of line, renders reliably):
|
||||
The neverending (blasting)
|
||||
Sound of the Bell
|
||||
```
|
||||
- Build echo density as intensity climbs — selective use beats every-line use.
|
||||
- Works best as single-word echoes in early verses, full-phrase echoes in later verses.
|
||||
- Confirmed working: Suno rendered `(blasting)` as a distinct backing vocal layer (once spaces-before-paren + paren-at-end-of-line rules were both applied).
|
||||
- **Long-paren fold-back fails as backing vocal (April 2026 LV data point):** A 10-syllable parenthetical like `(or at least that you think you need to be)` on its own line pulled as primary vocal rather than backing vocal interjection, even with triple-reinforcement (position-1 style-prompt descriptor + global `[Vocal Arrangement]` tag + per-section `[Vocal Style]` tags + paren-split into two shorter parens). Short parens (1-4 syllables) land as backing vocal interjections reliably; long parens (10+ syllables) pull as primary vocal continuation. The boundary is approximate — probably 5-7 syllables depending on context. When the fold-back logic requires a longer response phrase, the backing-vocal call-and-response effect may not land even with triple-reinforcement.
|
||||
- **Genre-dependent:** Parentheses produce true backing vocals in pop/R&B/soul/gospel/hip-hop contexts. In thrash/metal they come in as whispered phrases or ambience rather than a second voice. Not suitable for rapid intrusive-voice dialogue in heavy genres — see Dual Vocals section above for genre-appropriate alternatives.
|
||||
|
||||
**Doubled-word parentheticals — atmospheric/ritualistic backing (April 2026 production observation):**
|
||||
|
||||
Identical doubled words inside parens — `(plunging plunging)`, `(watching watching)`, `(caressing caressing)` — produce a ritualistic/trance group-vocal effect that intensifies the preceding lyrical image rather than echoing it. Different use case from the traditional `word(echo)` backing-vocal technique. Works well for psychedelic, swamp-blues, voodoo-atmosphere, gothic, and ritual-trance genres.
|
||||
|
||||
**Two production problems observed with doubled-word parentheticals:**
|
||||
|
||||
1. **Single-word truncation** — Suno sometimes renders `(plunging plunging)` as just `(plunging)`, interpreting the doubled word as a typo. **Fix: exclamation-separator.** `(plunging! plunging!)` forces Suno to read them as two distinct utterances by placing punctuation between. Genre caveat: exclamations trigger aggressive vocal attacks in metal and heavy-rock contexts — use with care outside psych/blues/folk/Americana/atmospheric-rock genres.
|
||||
|
||||
2. **First-section failure** — Suno uses the first lyrical section to establish the song's sonic palette. Non-default vocal arrangements (like group-backing-on-parens in rockabilly or psychedelic-blues, where backing vocals aren't the genre default) frequently fire on V2+ but MISS on V1 entirely. Once Suno "commits" to the absence of backing vocals in V1, it often continues inconsistently even if tags explicitly request them. See **"Establishing Non-Default Vocal Arrangements"** subsection below for production-tested remediation.
|
||||
|
||||
**Inline vs. line-separated parentheticals:** When the backing-vocal pattern fires inconsistently across verses, inline parentheticals (`The knife (plunging! plunging!)` on the same line as the lyric) are more reliable than line-separated indented parens. The line-separated style signals "spoken interjection" to Suno (see next subsection); inline signals "sung backing vocal."
|
||||
|
||||
### Establishing Non-Default Vocal Arrangements (April 2026)
|
||||
|
||||
When a song requires a non-default vocal arrangement — group backing vocals throughout, call-and-response, dual vocal interplay, parenthetical chants — that isn't typical for the target genre, Suno's first-section behavior frequently becomes load-bearing. Suno treats the first lyrical section as arrangement establishment; if the arrangement element doesn't fire on V1, Suno often "locks in" its absence and the pattern continues inconsistently through the rest of the song.
|
||||
|
||||
**Production-tested remediation: wordless-chant intro** — the most reliable single lever.
|
||||
|
||||
Add a dedicated `[Intro]` section with **non-lyrical content that demonstrates the vocal arrangement pattern before any story-bearing lyrics arrive**. Example:
|
||||
|
||||
```
|
||||
[Intro]
|
||||
[Instrumental groove with group vocal chants establishing the pattern]
|
||||
(oh oh) (ah ah) (oh oh) (ah ah)
|
||||
|
||||
[Verse 1]
|
||||
[Energy: hypnotic, established groove]
|
||||
[Vocal Style: lead with prominent group backing vocals on every parenthetical]
|
||||
The knife (plunging! plunging!)
|
||||
The door (slamming! slamming!)
|
||||
...
|
||||
```
|
||||
|
||||
Suno hears the pattern first, commits to it as part of the song's sonic identity, then applies it consistently through V1+.
|
||||
|
||||
**What does NOT work alone** (observed across multiple gens on a rockabilly-primary / psychedelic-blues-wild-card song, April 2026):
|
||||
|
||||
- **Renaming `[Verse 1]` to `[Intro]` without adding pre-lyrical content.** Section-type relabel doesn't carry enough weight. Tried across 1 Create (2 gens) — both missed backing vocals on the renamed-Intro section anyway.
|
||||
- **Strong per-verse `[Vocal Style:]` tags on V1 alone.** Suno interprets per-section vocal style tags as advisory and frequently ignores them for arrangement elements that would require the whole arrangement to shift (e.g., bringing in group backing vocals that the song "doesn't have").
|
||||
- **Global `[Vocal Arrangement:]` tag at the top of lyrics alone.** Necessary but not sufficient — contributes reinforcement only when combined with an actual pre-lyrical demonstration section.
|
||||
|
||||
**Belt-and-suspenders combination** (confirmed-working for group-backing-in-parens on Lenny-Soft v5.5, psychedelic swamp voodoo blues, April 2026):
|
||||
|
||||
1. Wordless-chant intro section demonstrating the pattern (primary lever)
|
||||
2. Global `[Vocal Arrangement: lead vocal with group responses on parenthetical lines throughout]` at the top of the lyrics block
|
||||
3. Per-section `[Vocal Style: lead with backing vocal in parenthesis]` on every verse
|
||||
4. Stronger-phrased tag on V1 specifically (`lead with prominent group backing vocals on every parenthetical`)
|
||||
5. Critical-zone style prompt placement: the arrangement descriptor at position 1 of the style prompt (e.g., `group backing vocals throughout, psychedelic swamp voodoo blues, ...`)
|
||||
6. Exclamation-separators on doubled-word parentheticals across all verses
|
||||
|
||||
**Energy tag interaction caution:** `[Energy: building]` on V1 can fight vocal-arrangement establishment. "Building" signals start-minimal-and-layer-in and may suppress group backing vocals Suno would otherwise include. When V1 needs the arrangement present from bar 1, use `[Energy: hypnotic, established groove]` or similar locked-in framing and reserve `[Energy: building]` for later verses where escalation is the actual goal.
|
||||
|
||||
**Why this pattern exists (hypothesis):** Suno's arrangement decisions appear to lock in early based on the first vocal section's delivery. Non-default vocal arrangements require Suno to "decide" the song has that arrangement — and the decision happens during the first sung section. A wordless intro with the pattern demonstrated gives Suno pre-commit evidence that the arrangement is part of the song's identity, not a per-section advisory.
|
||||
|
||||
**Isolated parentheticals as performed speech (April 2026 production observation):**
|
||||
|
||||
When parentheticals are placed on their own indented lines — not attached to a preceding line as `word(echo)` — Suno often delivers them as **spoken interjections** rather than sung backing vocal harmonies. This is a practical observation from production generations across multiple songs, not documented behavior.
|
||||
|
||||
```
|
||||
she's telling me about her day
|
||||
and I am making
|
||||
the right noises
|
||||
|
||||
(uh-huh)
|
||||
(sure)
|
||||
(really)
|
||||
(sorry to hear that)
|
||||
```
|
||||
|
||||
In this pattern, Suno tends to render `(uh-huh)`, `(sure)`, `(really)`, etc. as brief spoken interjections — a backing-vocal layer delivered as speech rather than singing. Works reliably across most genres including rock, Americana, adult alternative, and nu-metal (the `(He's lying!)` style in Schizo is an adjacent case).
|
||||
|
||||
**Practical implications:**
|
||||
- **Good for conversational/reactive interjections** (filler speech, reactions, asides) that shouldn't compete with the sung lead as harmony. The spoken delivery keeps them in the background without requiring a full `[Spoken Word]` section.
|
||||
- **Works with v5.5 Voices** even though Suno's documentation cautions that Voices aren't suitable for sustained spoken word. Brief parenthetical interjections are a different case from `[Spoken Word]`-tagged full sections — the interjection length is short enough that Voices don't drift.
|
||||
- **Fallback if not delivered spoken:** if a specific generation renders them as sung backing vocals instead of spoken, regenerate — the behavior is consistent across most gens but not 100% deterministic.
|
||||
- **Distinct from attached parentheticals** — `word(echo)` still works as the traditional backing-vocal echo technique. The isolated-line pattern is a different use case producing different behavior.
|
||||
|
||||
### Inline Performance Modifiers (HIGH)
|
||||
Parenthetical performance cues placed at the END of a lyric line to direct vocal delivery for that specific line. **This is a SEPARATE use of parentheses from backing vocals** — context determines interpretation. Backing vocals typically echo/repeat a word from the line; performance modifiers are delivery instructions.
|
||||
|
||||
| Cue | Effect | Example |
|
||||
|-----|--------|---------|
|
||||
| `(breathy)` | Breathy delivery on that line | `I can't stop thinking about you (breathy)` |
|
||||
| `(belt)` | Belted/powerful delivery | `HOLD ON (belt)` |
|
||||
| `(breath)` | Audible breath/pause | `wait for me... (breath)` |
|
||||
| `(hold)` | Sustained/held note | `stay with me (hold)` |
|
||||
|
||||
**Disambiguation from backing vocals:** Backing vocal parentheses contain lyric words that Suno sings as a second voice — e.g., `running through the fire(fire)`. Performance modifiers contain delivery instructions — e.g., `running through the fire (breathy)`. When in doubt, the presence of a recognizable delivery keyword (`breathy`, `belt`, `hold`, `breath`) signals a performance modifier.
|
||||
|
||||
### Structural Timing in Lyrics (HIGH)
|
||||
Direct timing instructions can be embedded in the lyrics field to control when vocals begin or end relative to the track duration:
|
||||
|
||||
```
|
||||
lyrics begin at 0:15; instrumental only after 1:45
|
||||
```
|
||||
|
||||
Place at the very top of the lyrics field before any section tags. This tells Suno to generate instrumental content before vocals start and/or after vocals end, providing explicit control over song structure timing.
|
||||
|
||||
### Line Density as Tempo Control
|
||||
This is the **PRIMARY mechanism** for controlling perceived tempo in Suno-generated vocals.
|
||||
|
||||
| Technique | Effect | Example |
|
||||
|-----------|--------|---------|
|
||||
| Short fragmented lines (1-3 words) | Slower delivery — each line gets its own phrase | `Fall` / `apart` / `slowly` |
|
||||
| Single words on their own line | Slows and strips down — creates dramatic pauses | `Gone` |
|
||||
| Long packed lines (many syllables) | Faster delivery — Suno compresses to fit | `Running through the city streets with nothing left to lose tonight` |
|
||||
| Sparse words, long lines | Slow, spacious feel | `Drifting... on... the... tide` |
|
||||
| Line breaks | Musical breaths — write breaks where you want the singer to breathe | |
|
||||
|
||||
**Key insight:** Word density is the PRIMARY mechanism for controlling perceived tempo. BPM tags have zero effect (confirmed by librosa — see Experimental Section Tags above). Energy metatags alone (`[Energy: high]`) do NOT reliably drive actual BPM shifts — they signal intensity but not tempo. Suno picks a single steady BPM for the entire song regardless of tags; what changes is *perceived* tempo through delivery density and arrangement.
|
||||
|
||||
**Why it works:** Librosa analysis confirms that BPM does not actually change between sections, even when sections *feel* dramatically different in speed. A "hustle bustle" section with packed syllables feels like acceleration, but the underlying tempo is identical. The perception of speed comes from how much vocal content Suno must deliver per beat.
|
||||
|
||||
**Recommended multi-technique approach for perceived tempo contrast:**
|
||||
The most effective tempo contrast uses these together — line density is the most reliable single technique:
|
||||
1. **Line density (PRIMARY)** — short fragmented lines for slow sections, packed lines for fast. Most reliable mechanism.
|
||||
2. **Half-time / double-time drum feel** — use rhythm nouns in metatags: `[Heavy: halftime]`, `[Double Time]`. Creates perception of halved or doubled tempo without BPM change. See below.
|
||||
3. **Instrumental density / arrangement dropout** — pulling instruments out creates space that feels slower. Adding everything back feels like acceleration. Use `[Energy: stripped, minimal]` for slow feel, `[Energy: massive]` for fast feel.
|
||||
4. **Line breaks as breath points** — more line breaks = more pauses = slower perceived delivery. Fewer breaks = longer phrases = faster feel. Write breaks where you want the singer to breathe.
|
||||
5. **Energy metatags** — `[Energy: low]` / `[Energy: high]` to signal intensity shifts (affects feel, not actual BPM)
|
||||
6. **Style prompt priming** — include "tempo changes" in the style prompt
|
||||
7. **Weirdness slider** (Pro/Premier) — higher values (60-65+ tested) encourage rhythmic variation
|
||||
|
||||
**Do NOT use BPM tags** — they are confirmed ineffective (see above). Each of the above techniques reinforces the others. Line density alone produces the most consistent results.
|
||||
|
||||
### Half-Time / Double-Time Drum Feel
|
||||
|
||||
Drums can switch to half-time snare patterns without the actual BPM changing, creating the perception of halved tempo. This is one of the most effective perceived tempo control techniques after line density.
|
||||
|
||||
| Tag | Effect | Notes |
|
||||
|-----|--------|-------|
|
||||
| `[Heavy: halftime]` | Half-time drum feel — snare on beat 3 only | Creates perception of halved tempo. Powerful for heavy/slow sections. |
|
||||
| `[Double Time]` | Double-time drum feel — snare on every beat | Creates perception of doubled tempo. Good for energy surges. |
|
||||
| `[Breakdown]` + halftime language | Stripped-back half-time section | Combine with short fragmented lines for maximum slow-down effect |
|
||||
|
||||
**Rhythm nouns over tempo adjectives:** "Halftime," "double-time," "shuffle," "breakbeat" lock rhythmic feel better than "slow," "fast," "upbeat." These nouns describe specific drum patterns Suno can interpret; adjectives like "slow" are vague and often ignored.
|
||||
|
||||
### Scream Bleed-Through Prevention
|
||||
Once Suno enters aggressive/scream mode, it tends to carry that energy forward into subsequent sections. Prevention strategies:
|
||||
|
||||
1. `[Vocal Style: whispered]` is a **harder vocal reset** than `[Vocal Style: clean]` — use after aggressive sections
|
||||
2. Every section after an aggressive one needs an explicit vocal style reset tag
|
||||
3. Never use `!` or ALL CAPS in sections immediately following an aggressive section
|
||||
4. Consider adding a `[Break]` or `[Instrumental]` buffer between aggressive and clean sections
|
||||
|
||||
### Spaced-Out Letters as Vocal Effect
|
||||
Placing spaces between every letter of a word — e.g., `R I G H T N E S S` — is a coin flip. Sometimes Suno spells out each letter individually, creating a powerful wall-of-sound moment. Sometimes it just sings the word normally. Not reliable enough to depend on. Worth trying for high-impact single words where a spelled-out delivery would be dramatic, but always have a fallback plan if Suno ignores it.
|
||||
|
||||
### Whispered Repeat as Closer
|
||||
Adding a final whispered repeat of the last word or phrase after the poem ends creates a powerful closing echo-into-silence effect. Suno handles this well — it's a good standard technique for closing tracks.
|
||||
```
|
||||
[Outro]
|
||||
Final lyric line here
|
||||
|
||||
[Whispered]
|
||||
Forever
|
||||
|
||||
[End]
|
||||
```
|
||||
The `[Whispered]` tag before the single repeated word, followed by `[End]`, produces a natural fade-to-silence moment. Use the most resonant word from the final line or the song's central image.
|
||||
|
||||
### Vowel Stretching & Syllable Manipulation
|
||||
| Technique | Effect |
|
||||
|-----------|--------|
|
||||
| `loooove`, `feeeel` | Nudges cadence — extended vowels suggest held/sustained delivery |
|
||||
| `to-o-o-lling` | Hyphenated vowel extension can stretch a word for dramatic effect — results vary |
|
||||
| Use sparingly | Test iteratively — results are inconsistent |
|
||||
|
||||
### Pronunciation / Phonetics
|
||||
Suno has no dictionary — it guesses pronunciation from spelling patterns. This creates problems with homographs and unusual words.
|
||||
|
||||
- **Homographs are the biggest problem:** `lives` (verb "he lives" vs noun "our lives"), `read`, `lead` — Suno picks one pronunciation and may guess wrong.
|
||||
- **Context from surrounding words does NOT reliably help** Suno pick the right pronunciation.
|
||||
- **Phonetic spelling fixes:** `through` to `thru`, `lives` (verb) to `livz`, `Breaths` (verb) to `Breethz`.
|
||||
- **Hyphenation forces syllable breaks:** `to-night`, `liv-uz`.
|
||||
- **Only use phonetic spelling where a word has more than one valid reading** — don't phonetically spell unambiguous words.
|
||||
- **Keep original spelling in the songbook** and note the phonetic substitution in the Suno lyrics version.
|
||||
- **Post-generation lyric editing works** for pronunciation fixes — generate, listen, then fix spellings and re-generate if needed.
|
||||
|
||||
#### Mid-Word Vowel Anchoring with English-Word Fragments
|
||||
|
||||
When a word's mispronunciation is localized to one syllable (typical for Latin terms, scientific vocabulary, or unusual proper nouns), respell ONLY that syllable with an English-word fragment that unambiguously encodes the target vowel sound. The principle: hand Suno a spelling-pattern it has clearly trained on, mid-word, in place of the ambiguous original.
|
||||
|
||||
**Example — broken and fixed (The Life of Walther Who?, April 2026):**
|
||||
- Broken: `ad infinitum` → Suno reads "ahd in-fih-NIH-tuhm" (short-i in the stressed syllable, wrong)
|
||||
- Fixed: `ad in-fih-nigh-tum` → Suno reads "ahd in-fih-NIGH-tuhm" (long-i correct, Anglicized pronunciation lands)
|
||||
- Result: production-confirmed clean delivery on regen 2026-04-29 with `nigh` lowercase
|
||||
|
||||
**Why `nigh` works:** It's an English word with unambiguous long-i pronunciation (rhymes with high/sigh/thigh). Suno's spelling-pattern prediction has clearly trained on it. The hyphenation `in-fih-nigh-tum` forces syllable breaks; the phonetic anchor sits inside that hyphenated structure and Suno renders the long-i without drifting to a more common nearby word.
|
||||
|
||||
**Common mid-word vowel anchors (English fragments, all uniquely-pronounced in standard English):**
|
||||
- **Long-i:** `nigh`, `eye`, `igh` (stretched only — see Stretched Words section), `nye` / `dye` / `rye` family
|
||||
- **Long-a:** `way`, `ray`, `bay` family
|
||||
- **Long-o:** `oh`, `dough`, `toe`, `bow` (where unambiguous)
|
||||
- **Long-e:** `ee`, `bee`, `tea`
|
||||
- **Long-u (yoo):** `you`, `cue`, `due`
|
||||
- **Long-u (oo):** `boot`, `moo`, `flu`
|
||||
|
||||
**How to apply:**
|
||||
1. Identify the syllable Suno is mispronouncing (single syllable, usually).
|
||||
2. Identify the target vowel sound (long-i, long-a, etc.).
|
||||
3. Substitute that syllable with an English-word fragment containing the target sound.
|
||||
4. Hyphenate to force the syllable break around the substitution: `original-fix-original`.
|
||||
5. Per the "phonetics only where ambiguous" principle, leave the syllables Suno gets right untouched. `ad infinitum` doesn't need `ad` and `tum` respelled — only the broken `nih` syllable.
|
||||
|
||||
**Capitalization on phonetic anchors:** ALL CAPS on a phonetic-anchor syllable adds delivery loudness/intensity per the Capitalization Effects section above — NOT a different pronunciation. `nigh` and `NIGH` are pronounced the same; `NIGH` just gets sung louder. Use ALL CAPS on the phonetic anchor only when (a) the syllable is naturally stressed in correct pronunciation AND (b) the loudness boost serves the section's dynamic (not, e.g., a quiet verse where one boosted syllable would be jarring).
|
||||
|
||||
**Distinct from Stretched Words guidance** (next section): that guidance covers DRAMATIC ELONGATION via hyphenated repeated letters (`to-o-o-lling`); this guidance covers NON-STRETCHED mid-word fixes for normal-tempo delivery. Both use the principle of substituting unambiguous English-word fragments, but apply in different contexts.
|
||||
|
||||
### Open-Ended Instrumental Sections Are Dangerous
|
||||
Instrumental tags without clear boundaries cause Suno to generate excessive instrumental content:
|
||||
|
||||
- `[Guitar Solo]` works if followed by more vocals or `[End]`.
|
||||
- `[Instrumental section — full prog, complex]` = Suno noodles indefinitely.
|
||||
- Multiple `[Instrumental break]` tags = the song becomes mostly instrumental.
|
||||
- **Always put `[End]` hard after the final vocal section or solo** to prevent trailing generation.
|
||||
|
||||
## Placement Rules
|
||||
|
||||
1. **Global descriptors** at the TOP of the lyrics — these set the overall tone
|
||||
2. **Section-specific descriptors** RIGHT BEFORE the section they apply to — these override/refine the global
|
||||
3. Section-specific tags are more effective than global tags
|
||||
4. Don't over-tag — 1-2 descriptors per section maximum, fewer is often better
|
||||
5. Metatags work best when short: 1-3 words, not full sentences
|
||||
6. Tags are most impactful in the first 20-30 words and around section changes
|
||||
|
||||
## Formatting Rules
|
||||
|
||||
- Blank line between every section (including between tag and previous section)
|
||||
- No style descriptions inside lyrics text (those go in the style prompt)
|
||||
- No asterisks or markdown formatting in lyrics (exception: `*text*` for inline sound effects — see Asterisk Inline Sound Effects)
|
||||
- Commas create breath pauses, dashes create connected delivery, ellipses create micro-pauses — use intentionally
|
||||
- **Exclamation points trigger bark/attack delivery** — avoid in clean sections
|
||||
- **ALL CAPS sets the loudness ceiling** — save for peak moments only
|
||||
- **Parentheses signal backing vocals** — not lead melody (but also used for inline performance modifiers like `(breathy)`, `(belt)` — see Inline Performance Modifiers section)
|
||||
- Consistent line lengths within a section improve phrasing stability
|
||||
- Line density (short vs long lines) is the primary tempo control mechanism
|
||||
|
||||
## Example with Instrumental Sections
|
||||
|
||||
```
|
||||
[Mood: bittersweet]
|
||||
[Vocal Style: intimate]
|
||||
|
||||
[Intro]
|
||||
|
||||
[Verse 1]
|
||||
Walking through the fog of early morning light
|
||||
Counting all the windows still awake
|
||||
Every shadow holds a name I used to know
|
||||
Every corner bends but doesn't break
|
||||
|
||||
[Pre-Chorus]
|
||||
And I keep reaching for the thread
|
||||
That ties me to some other when
|
||||
|
||||
[Chorus]
|
||||
[Belted]
|
||||
Come undone, come undone
|
||||
Let the weight fall where it may
|
||||
|
||||
[Interlude]
|
||||
[Guitar Solo]
|
||||
|
||||
[Verse 2]
|
||||
[Whispered]
|
||||
Fingerprints on glass that someone cleaned away
|
||||
Letters folded into paper cranes
|
||||
|
||||
[Chorus]
|
||||
Come undone, come undone
|
||||
Let the weight fall where it may
|
||||
|
||||
[Bridge]
|
||||
[Energy: stripped back]
|
||||
Maybe what we lost was just the frame
|
||||
And the picture's hanging somewhere still
|
||||
|
||||
[Final Chorus]
|
||||
[Energy: building]
|
||||
[Belted]
|
||||
Come undone, come undone
|
||||
Let the weight fall where it may
|
||||
We were never meant to stay
|
||||
|
||||
[Outro]
|
||||
[Hummed]
|
||||
[Fade Out]
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
- [Suno Help: How long will my song be?](https://help.suno.com/en/articles/2409473)
|
||||
- [HookGenius: All Suno Metatags Complete List (2026)](https://hookgenius.app/learn/suno-metatags-complete-list/)
|
||||
- [HookGenius: The Art of Prompting Suno](https://hookgenius.app/learn/art-of-prompting-suno/)
|
||||
- [HookGenius: Suno Negative Prompting Guide](https://hookgenius.app/learn/suno-negative-prompting/)
|
||||
- [HookGenius: Suno v5 Complete Guide](https://hookgenius.app/learn/suno-v5-complete-guide/)
|
||||
- [HookGenius: Suno Character Limits](https://hookgenius.app/learn/suno-character-limits/)
|
||||
- [Musci.io: Suno Tags List Complete Guide (2026)](https://musci.io/blog/suno-tags)
|
||||
- [Suno Wiki: List of Metatags](https://sunoaiwiki.com/resources/2024-05-13-list-of-metatags/)
|
||||
- [SunoMetaTagCreator: Complete Guide (1000+ tags)](https://sunometatagcreator.com/metatags-guide)
|
||||
- [OpenMusicPrompt: 500+ Pro Tags & Templates (2026)](https://openmusicprompt.com/blog/suno-ai-metatags-guide)
|
||||
- [BlakeCrosley: Suno AI Definitive Technical Reference](https://blakecrosley.com/guides/suno)
|
||||
- [Lilys/Suno Prompting Secrets](https://lilys.ai/notes/en/suno-ai-v5-20251020/suno-prompting-secrets-powerful-metatags)
|
||||
- [StokeMcToke: Complete Suno AI Meta Tags Guide](https://stokemctoke.com/the-complete-suno-ai-meta-tags-guide/)
|
||||
- [JackRighteous: Suno AI Meta Tags Guide](https://jackrighteous.com/en-us/pages/suno-ai-meta-tags-guide)
|
||||
- [CometAPI: How to Instruct Suno v5 with Lyrics](https://www.cometapi.com/how-to-instruct-suno-v5-with-lyrics/)
|
||||
- [MusicSmith: AI Music Generation Prompts Best Practices](https://musicsmith.ai/blog/ai-music-generation-prompts-best-practices)
|
||||
- [howtopromptsuno.com: Voice Tags Guide](https://howtopromptsuno.com/making-music/voice-tags)
|
||||
- [Plain English: 10 Suno v5 Prompt Patterns That Never Miss](https://plainenglish.io/blog/i-made-10-suno-v5-prompt-patterns-that-never-miss)
|
||||
- [HookGenius: Suno v5.5 Guide — Voices, Custom Models & My Taste](https://hookgenius.app/learn/suno-v5-5-guide/)
|
||||
- [HookGenius: 300+ Suno Style Tags That Actually Work (2026)](https://hookgenius.app/learn/suno-style-tags-guide/)
|
||||
- [HookGenius: Suno Prompts Complete Guide](https://hookgenius.app/learn/suno-prompts-complete-guide/)
|
||||
- [Suno API Docs: Character Limits by Model (sunoapi.org)](https://docs.sunoapi.org/suno-api/generate-music)
|
||||
- [iFlow.bot: Suno v5 Secrets](https://iflow.bot/suno-v5-secrets-crafting-ai-generated-songs/)
|
||||
|
||||
## Community Research Sources
|
||||
|
||||
> Last updated: April 6, 2026.
|
||||
|
||||
- [HookGenius: All Suno Metatags Complete List (2026)](https://hookgenius.app/learn/suno-metatags-complete-list/)
|
||||
- [HookGenius: 300+ Suno Style Tags That Actually Work](https://hookgenius.app/learn/suno-style-tags-guide/)
|
||||
- [HookGenius: Suno Vocal Effects — Harmonies, Layers & More](https://hookgenius.app/learn/suno-vocal-effects/)
|
||||
- [Jack Righteous: Suno AI Meta Tags Guide](https://jackrighteous.com/en-us/pages/suno-ai-meta-tags-guide)
|
||||
- [Jack Righteous: Add Sound Effects Using Asterisks](https://jackrighteous.com/en-us/blogs/guides-using-suno-ai-music-creation/suno-ai-sound-effects-asterisks)
|
||||
- [Jack Righteous: Mastering Suno V5 Meta Tags — 2nd Edition](https://jackrighteous.com/en-us/blogs/jack-righteous-updates/mastering-suno-v5-meta-tags-2nd-edition-update-how-to-use)
|
||||
- [BlakeCrosley: Suno AI Definitive Technical Reference](https://blakecrosley.com/guides/suno)
|
||||
- [OpenMusicPrompt: 500+ Pro Tags & Templates](https://openmusicprompt.com/blog/suno-ai-metatags-guide)
|
||||
- [James 99/Medium: Ultimate Guide to Suno AI Metatags](https://james-palm.medium.com/stop-wasting-your-credits-the-ultimate-guide-to-suno-ai-metatags-verse-chorus-and-drop-57e209a0e5d8)
|
||||
151
.agent/skills/suno-lyric-transformer/references/section-jobs.md
Normal file
151
.agent/skills/suno-lyric-transformer/references/section-jobs.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Section Job Framework
|
||||
|
||||
> **Last validated:** March 2026. Section job definitions are songwriting craft principles, not Suno-specific — they do not require re-validation with Suno updates.
|
||||
|
||||
Every song section has a specific job in the emotional arc. Understanding these jobs is critical for deciding where to place lyrics and how to structure a poem-to-song transformation.
|
||||
|
||||
## Section Roles
|
||||
|
||||
| Section | Job | Emotional Function | Typical Lines |
|
||||
|---------|-----|--------------------|---------------|
|
||||
| **Intro** | Set the stage | Create atmosphere, establish mood before words | 0-4 (often instrumental) |
|
||||
| **Verse** | Setup / Tell the story | Deliver narrative, build context, paint scenes | 4-8 |
|
||||
| **Pre-Chorus** | Lift / Create tension | Transitional energy rise, prepare for payoff | 2-4 |
|
||||
| **Chorus** | Payoff / Emotional anchor | Deliver the hook, the core feeling, the thing that sticks | 2-6 |
|
||||
| **Bridge** | Something NEW / Contrast | New chords, new melody, new perspective. Introduces harmonic content the song hasn't heard yet | 2-6 |
|
||||
| **Breakdown** | Something LESS / Strip back | Subtractive — strips instruments to spotlight vocals or a motif. In metal, forces tempo drop and heavy rhythm. Creates maximum contrast before high-energy sections | 2-4 |
|
||||
| **Build-Up** | Escalate / Rising tension | Increasing energy leading to climax | 2-4 |
|
||||
| **Outro** | Resolve / Close | Bring it home — resolution, fade, final statement | 2-6 |
|
||||
|
||||
## Transformation Decision Guide
|
||||
|
||||
When converting raw text to song structure, ask these questions:
|
||||
|
||||
### "Where's the hook?"
|
||||
- The most emotionally resonant, imagistic, or rhythmic line(s)
|
||||
- This becomes the chorus or chorus seed
|
||||
- If no obvious hook exists, derive one from the poem's central image or feeling
|
||||
|
||||
### "Where's the turn?"
|
||||
- The moment the perspective shifts, deepens, or surprises
|
||||
- This becomes the bridge
|
||||
- Poems without a turn may need a bridge written to provide contrast
|
||||
|
||||
### "What's the story arc?"
|
||||
- Lines that set scenes or provide context → verses
|
||||
- Lines that build tension → pre-chorus
|
||||
- Lines that release/resolve → chorus or outro
|
||||
|
||||
### "What should repeat?"
|
||||
- Repetition = emphasis = memorability
|
||||
- The chorus repeats. What phrase deserves to be heard 3+ times?
|
||||
- Consider also: anaphora (repeated line openings), callbacks (later sections echoing earlier phrases)
|
||||
|
||||
## Common Poem-to-Song Structures
|
||||
|
||||
### Short Poem (8-16 lines)
|
||||
```
|
||||
Verse 1 (first half of poem)
|
||||
Chorus (derived from emotional core)
|
||||
Verse 2 (second half of poem)
|
||||
Chorus
|
||||
```
|
||||
|
||||
### Song Duration — Let the Words Decide
|
||||
Not all songs need to be 3-4 minutes. A short duration (e.g., 1:49) can be a feature when it matches the emotional content. Don't pad short poems just for runtime — let the song be the length the words demand. Short tracks create contrast in a playlist between longer epic tracks and short punches. A 90-second song that lands every line hits harder than a 3-minute song with filler.
|
||||
|
||||
### Very Short Poem (under 15 lines)
|
||||
Poems under 15 lines need special handling — Suno fills short content with looping instrumental, producing a song that feels empty or aimless. Strategies:
|
||||
|
||||
**Double delivery:** Deliver the poem twice with different energy. Clean/quiet first pass, then heavy/intense second pass. The repetition is intentional — the same words change meaning through musical recontextualization. This works when the poem's meaning deepens or shifts under a different emotional lens.
|
||||
```
|
||||
Verse 1 (full poem, clean delivery)
|
||||
Chorus (extracted hook)
|
||||
Verse 2 (full poem, heavy delivery)
|
||||
Final Chorus
|
||||
```
|
||||
|
||||
**Chorus extraction:** Pull the poem's strongest, most repeatable lines into a standalone chorus. This gives Suno enough structural repetition to build a full song around limited source text.
|
||||
|
||||
**Thesis isolation:** Build through the poem, add a guitar solo or instrumental break, then deliver ONLY the final thesis statement as its own section. Powerful when the poem has a clear thesis line that deserves to land in isolation.
|
||||
```
|
||||
Verse 1
|
||||
Verse 2
|
||||
Guitar Solo
|
||||
Outro (thesis line only)
|
||||
[End]
|
||||
```
|
||||
|
||||
**What NOT to do:** Do not pad short poems with `[Instrumental break]` tags in the lyrics — this literally asks Suno to noodle and produces a song that is mostly instrumental filler.
|
||||
|
||||
### Medium Poem (16-30 lines)
|
||||
```
|
||||
Verse 1
|
||||
Pre-Chorus
|
||||
Chorus
|
||||
Verse 2
|
||||
Pre-Chorus
|
||||
Chorus
|
||||
Bridge (the "turn" or a new perspective)
|
||||
Final Chorus
|
||||
```
|
||||
|
||||
### Long Poem (30+ lines)
|
||||
```
|
||||
Verse 1
|
||||
Chorus
|
||||
Verse 2
|
||||
Chorus
|
||||
Bridge
|
||||
Verse 3 (or shortened recap)
|
||||
Final Chorus
|
||||
Outro
|
||||
```
|
||||
|
||||
### Poem That Doesn't Need a Chorus
|
||||
Some poems are genuinely better as continuous narrative. Signs:
|
||||
- The poem is a single sustained meditation with no natural hook
|
||||
- Adding repetition would break the flow
|
||||
- The emotional power is in the progression, not a single moment
|
||||
|
||||
In this case, structure as:
|
||||
```
|
||||
Verse 1
|
||||
Verse 2
|
||||
Bridge
|
||||
Verse 3
|
||||
Outro
|
||||
[End]
|
||||
```
|
||||
Use descriptor metatags to guide energy changes instead of relying on chorus repetition.
|
||||
|
||||
### Through-Composed Structure — Production Notes
|
||||
Through-composed (no repeating chorus) works well when:
|
||||
- The poem has a clear arc: building tension, climax, resolution.
|
||||
- Word density naturally drives dynamic shifts — dense lines for intensity, sparse lines for breathing room.
|
||||
- The style prompt supports the dynamic range needed (e.g., a style prompt that includes both quiet and heavy descriptors).
|
||||
|
||||
Critical requirement: always place a hard `[End]` tag after the final delivery to prevent Suno from looping or generating trailing instrumental. Without `[End]`, through-composed songs are especially prone to meandering because Suno has no chorus to signal "this is the structure repeating."
|
||||
|
||||
## Structural Metaphor in Song Design
|
||||
|
||||
Different time signatures for different section types can serve as a form-serves-content technique — the musical structure itself becomes a storytelling device. When a poem's themes lend themselves to it, the Lyric Transformer should consider suggesting structural metaphors where the musical form embodies the lyrical meaning.
|
||||
|
||||
### Examples
|
||||
|
||||
| Lyrical Theme | Musical Treatment | Effect |
|
||||
|---|---|---|
|
||||
| Chaos, instability, disorientation | Odd time signatures (5/4, 7/8) in verses | The listener feels off-balance, mirroring the content |
|
||||
| Resolution, arrival, clarity | Straight 4/4 in choruses | Landing on solid ground after rhythmic instability |
|
||||
| Freedom, looseness | NOLA funk groove, swung rhythms | The music breathes and moves freely |
|
||||
| Confinement, rigidity, control | Rigid tempo, pounding metronomic drums | Mechanical precision creates a trapped feeling |
|
||||
| Building dread | Accelerating tempo or increasing rhythmic density | Tension ratchets up through the music itself |
|
||||
|
||||
### Application Guidance
|
||||
|
||||
This technique is most powerful for prog and through-composed structures where the musical journey parallels the lyrical journey. The Lyric Transformer should flag opportunities for structural metaphor when:
|
||||
- The poem has contrasting emotional states across sections (e.g., turmoil in verses, peace in choruses)
|
||||
- The poem's themes include concepts that have natural musical analogs (freedom/confinement, chaos/order, tension/release)
|
||||
- The target genre supports rhythmic experimentation (prog, post-metal, NOLA funk — less applicable to straightforward rock/pop)
|
||||
|
||||
Note: Time signature changes are inconsistently respected by Suno (see metatag-reference.md experimental tags), so structural metaphor should be treated as aspirational — worth attempting for the payoff when it lands, but not something to depend on for the song to work.
|
||||
321
.agent/skills/suno-lyric-transformer/scripts/analyze-input.py
Normal file
321
.agent/skills/suno-lyric-transformer/scripts/analyze-input.py
Normal file
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Pre-analyze raw input text to extract deterministic metrics before LLM processing.
|
||||
|
||||
Detects existing structure, counts lines/words/characters, finds repeated phrases,
|
||||
identifies potential rhyme pairs, and estimates needed structure.
|
||||
|
||||
Usage:
|
||||
python analyze-input.py <text-file> [options]
|
||||
|
||||
# Analyze input from a file
|
||||
python analyze-input.py input.txt
|
||||
|
||||
# Analyze from text argument
|
||||
python analyze-input.py --text "Some raw lyrics text"
|
||||
|
||||
# Output to file
|
||||
python analyze-input.py input.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import SUNO_LYRICS_HARD_LIMIT, SUNO_LYRICS_QUALITY_BUDGET
|
||||
|
||||
SCRIPT_NAME = "analyze-input"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
def find_metatags(text: str) -> list[str]:
|
||||
"""Find all metatag-style brackets in text."""
|
||||
return re.findall(r'\[([^\]]+)\]', text)
|
||||
|
||||
|
||||
def find_repeated_phrases(text: str, min_words: int = 3, min_count: int = 2) -> list[dict]:
|
||||
"""Find exact phrase matches of min_words+ words appearing min_count+ times."""
|
||||
lines = text.split('\n')
|
||||
# Collect all non-empty, non-tag lines
|
||||
content_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not re.match(r'^\[.*\]$', stripped):
|
||||
content_lines.append(stripped)
|
||||
|
||||
# Build n-grams from all content
|
||||
all_words = []
|
||||
for line in content_lines:
|
||||
words = re.findall(r"[a-zA-Z']+", line.lower())
|
||||
all_words.extend(words)
|
||||
|
||||
phrases = Counter()
|
||||
for n in range(min_words, min(8, len(all_words) + 1)):
|
||||
for i in range(len(all_words) - n + 1):
|
||||
phrase = " ".join(all_words[i:i + n])
|
||||
phrases[phrase] += 1
|
||||
|
||||
# Filter and deduplicate (remove sub-phrases if a longer phrase has same count)
|
||||
results = {}
|
||||
for phrase, count in phrases.items():
|
||||
if count >= min_count:
|
||||
results[phrase] = count
|
||||
|
||||
# Remove sub-phrases where a longer phrase has the same count
|
||||
filtered = {}
|
||||
sorted_phrases = sorted(results.keys(), key=len, reverse=True)
|
||||
for phrase in sorted_phrases:
|
||||
count = results[phrase]
|
||||
# Check if this is a sub-phrase of an already-kept longer phrase with same count
|
||||
is_sub = False
|
||||
for kept in filtered:
|
||||
if phrase in kept and filtered[kept] == count:
|
||||
is_sub = True
|
||||
break
|
||||
if not is_sub:
|
||||
filtered[phrase] = count
|
||||
|
||||
return [{"phrase": p, "count": c} for p, c in sorted(filtered.items(), key=lambda x: -x[1])]
|
||||
|
||||
|
||||
def find_rhyme_pairs(text: str) -> list[dict]:
|
||||
"""Find potential rhyme pairs based on ending sounds (last 2-3 chars)."""
|
||||
lines = text.split('\n')
|
||||
content_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not re.match(r'^\[.*\]$', stripped):
|
||||
content_lines.append(stripped)
|
||||
|
||||
# Extract last word of each line
|
||||
line_endings = []
|
||||
for i, line in enumerate(content_lines):
|
||||
words = re.findall(r"[a-zA-Z']+", line)
|
||||
if words:
|
||||
line_endings.append((i, words[-1].lower()))
|
||||
|
||||
pairs = []
|
||||
seen = set()
|
||||
|
||||
for idx in range(len(line_endings)):
|
||||
# Check consecutive and alternating lines
|
||||
for offset in (1, 2):
|
||||
if idx + offset < len(line_endings):
|
||||
i, word_a = line_endings[idx]
|
||||
j, word_b = line_endings[idx + offset]
|
||||
|
||||
if word_a == word_b:
|
||||
continue
|
||||
|
||||
# Check if last 2 or 3 characters match
|
||||
match_len = 0
|
||||
if len(word_a) >= 2 and len(word_b) >= 2 and word_a[-2:] == word_b[-2:]:
|
||||
match_len = 2
|
||||
if len(word_a) >= 3 and len(word_b) >= 3 and word_a[-3:] == word_b[-3:]:
|
||||
match_len = 3
|
||||
|
||||
if match_len > 0:
|
||||
pair_key = tuple(sorted([word_a, word_b]))
|
||||
if pair_key not in seen:
|
||||
seen.add(pair_key)
|
||||
pairs.append({
|
||||
"words": [word_a, word_b],
|
||||
"ending_match": word_a[-match_len:],
|
||||
"pattern": "consecutive" if offset == 1 else "alternating"
|
||||
})
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def estimate_structure(line_count: int) -> dict:
|
||||
"""Estimate structure category and needed sections from line count."""
|
||||
if line_count < 16:
|
||||
return {
|
||||
"estimated_structure": "short",
|
||||
"estimated_sections_needed": max(3, line_count // 4)
|
||||
}
|
||||
elif line_count <= 30:
|
||||
return {
|
||||
"estimated_structure": "medium",
|
||||
"estimated_sections_needed": max(5, line_count // 5)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"estimated_structure": "long",
|
||||
"estimated_sections_needed": max(7, line_count // 5)
|
||||
}
|
||||
|
||||
|
||||
def analyze_input(text: str) -> dict:
|
||||
"""Analyze input text and extract metrics."""
|
||||
lines = text.split('\n')
|
||||
non_empty_lines = [line for line in lines if line.strip()]
|
||||
content_lines = [line.strip() for line in lines if line.strip() and not re.match(r'^\[.*\]$', line.strip())]
|
||||
|
||||
# Detect metatags
|
||||
existing_tags = find_metatags(text)
|
||||
has_existing_structure = any(
|
||||
re.match(r'^(verse|chorus|bridge|intro|outro|pre-chorus|hook|refrain|breakdown|build-up)', tag.lower())
|
||||
for tag in existing_tags
|
||||
)
|
||||
|
||||
# Counts
|
||||
word_count = sum(len(line.split()) for line in content_lines)
|
||||
char_count = len(text)
|
||||
|
||||
# Repeated phrases
|
||||
repeated = find_repeated_phrases(text)
|
||||
|
||||
# Rhyme pairs
|
||||
rhymes = find_rhyme_pairs(text)
|
||||
|
||||
# Structure estimate (based on content lines)
|
||||
structure = estimate_structure(len(content_lines))
|
||||
|
||||
return {
|
||||
"has_existing_structure": has_existing_structure,
|
||||
"existing_tags": existing_tags,
|
||||
"line_count": len(lines),
|
||||
"non_empty_line_count": len(non_empty_lines),
|
||||
"word_count": word_count,
|
||||
"character_count": char_count,
|
||||
"repeated_phrases": repeated,
|
||||
"potential_rhyme_pairs": rhymes,
|
||||
**structure
|
||||
}
|
||||
|
||||
|
||||
def build_report(analysis: dict, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = []
|
||||
|
||||
if analysis["has_existing_structure"]:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "structure",
|
||||
"issue": "Input already contains section metatags.",
|
||||
"fix": "May need restructuring rather than initial structuring."
|
||||
})
|
||||
|
||||
if analysis["character_count"] > SUNO_LYRICS_HARD_LIMIT:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "length",
|
||||
"issue": f"Character count ({analysis['character_count']}) exceeds Suno's {SUNO_LYRICS_HARD_LIMIT}-character hard limit.",
|
||||
"fix": f"Trim to stay under {SUNO_LYRICS_HARD_LIMIT} characters. For best quality, aim for ~{SUNO_LYRICS_QUALITY_BUDGET}."
|
||||
})
|
||||
elif analysis["character_count"] > SUNO_LYRICS_QUALITY_BUDGET:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "length",
|
||||
"issue": f"Character count ({analysis['character_count']}) exceeds the ~{SUNO_LYRICS_QUALITY_BUDGET}-character quality budget.",
|
||||
"fix": f"Consider trimming — quality degrades above ~{SUNO_LYRICS_QUALITY_BUDGET} characters. Hard limit is {SUNO_LYRICS_HARD_LIMIT}."
|
||||
})
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["medium"] > 0:
|
||||
status = "info"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"has_existing_structure": analysis["has_existing_structure"],
|
||||
"existing_tags": analysis["existing_tags"],
|
||||
"line_count": analysis["line_count"],
|
||||
"non_empty_line_count": analysis["non_empty_line_count"],
|
||||
"word_count": analysis["word_count"],
|
||||
"character_count": analysis["character_count"],
|
||||
"repeated_phrases": analysis["repeated_phrases"],
|
||||
"potential_rhyme_pairs": analysis["potential_rhyme_pairs"],
|
||||
"estimated_structure": analysis["estimated_structure"],
|
||||
"estimated_sections_needed": analysis["estimated_sections_needed"],
|
||||
},
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pre-analyze raw input text to extract deterministic metrics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s input.txt
|
||||
%(prog)s --text "Some raw lyrics\\nAnother line"
|
||||
%(prog)s --stdin < input.txt
|
||||
%(prog)s input.txt -o results.json --verbose
|
||||
|
||||
Metrics extracted:
|
||||
- Existing metatags and structure detection
|
||||
- Line, word, and character counts
|
||||
- Repeated phrases (3+ words, 2+ occurrences)
|
||||
- Potential rhyme pairs (shared endings)
|
||||
- Estimated structure size (short/medium/long)
|
||||
|
||||
Exit codes: 0=pass, 1=issues, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to text file")
|
||||
parser.add_argument("--text", help="Text to analyze directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read text from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Analyzing input ({len(text)} chars, {len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
analysis = analyze_input(text)
|
||||
report = build_report(analysis, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
231
.agent/skills/suno-lyric-transformer/scripts/assemble-summary.py
Normal file
231
.agent/skills/suno-lyric-transformer/scripts/assemble-summary.py
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Assemble Transformation Summary from validation, syllable, and cliche reports.
|
||||
|
||||
Collects outputs from validate-lyrics.py, syllable-counter.py, and cliche-detector.py
|
||||
and assembles a formatted Transformation Summary markdown block.
|
||||
|
||||
Usage:
|
||||
python assemble-summary.py --validation val.json --syllables syl.json --cliches cli.json [options]
|
||||
|
||||
# Assemble from three JSON files
|
||||
python assemble-summary.py --validation val.json --syllables syl.json --cliches cli.json
|
||||
|
||||
# With transformation codes
|
||||
python assemble-summary.py --validation val.json --syllables syl.json --cliches cli.json --transformations "ST,CC,RA"
|
||||
|
||||
# Output to file
|
||||
python assemble-summary.py --validation val.json --syllables syl.json --cliches cli.json -o summary.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "assemble-summary"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
CODE_DESCRIPTIONS = {
|
||||
"ST": "Structural Transformation",
|
||||
"CE": "Cliche Elimination",
|
||||
"CC": "Consistency Check",
|
||||
"RA": "Rhyme Analysis",
|
||||
"FR": "Full Rewrite",
|
||||
"CD": "Cliche Detection",
|
||||
"WF": "Word Flow",
|
||||
}
|
||||
|
||||
# Approximate duration: ~15 seconds per section on average
|
||||
SECONDS_PER_SECTION = 15
|
||||
|
||||
|
||||
def load_json_file(path: str) -> dict:
|
||||
"""Load and parse a JSON file."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return {}
|
||||
return json.loads(p.read_text())
|
||||
|
||||
|
||||
def assemble_summary(validation: dict, syllables: dict, cliches: dict,
|
||||
transformations: list[str]) -> dict:
|
||||
"""Assemble summary data from the three reports."""
|
||||
# Extract from validation report
|
||||
val_metrics = validation.get("metrics", {})
|
||||
section_count = val_metrics.get("section_count", 0)
|
||||
section_types = val_metrics.get("sections", [])
|
||||
val_status = validation.get("status", "unknown")
|
||||
lyric_lines = val_metrics.get("lyric_lines", 0)
|
||||
total_lines = val_metrics.get("total_lines", 0)
|
||||
|
||||
# Estimate character count from validation raw data or total lines
|
||||
char_count = 0
|
||||
if "raw_text" in validation:
|
||||
char_count = len(validation["raw_text"])
|
||||
|
||||
# Extract from syllable report
|
||||
syl_metrics = syllables.get("metrics", {})
|
||||
min_syl = syl_metrics.get("min_syllables", 0)
|
||||
max_syl = syl_metrics.get("max_syllables", 0)
|
||||
avg_syl = syl_metrics.get("average_syllables_per_line", 0)
|
||||
total_syl = syl_metrics.get("total_syllables", 0)
|
||||
|
||||
# Extract from cliche report
|
||||
cli_metrics = cliches.get("metrics", {})
|
||||
total_cliches = cli_metrics.get("total_cliches_found", 0)
|
||||
cliche_categories = cli_metrics.get("categories", {})
|
||||
cli_status = cliches.get("status", "unknown")
|
||||
|
||||
# Estimated duration
|
||||
estimated_duration_sec = section_count * SECONDS_PER_SECTION
|
||||
minutes = estimated_duration_sec // 60
|
||||
seconds = estimated_duration_sec % 60
|
||||
|
||||
# Transformation descriptions
|
||||
trans_descriptions = [
|
||||
f"- {code}: {CODE_DESCRIPTIONS.get(code, code)}"
|
||||
for code in transformations
|
||||
]
|
||||
|
||||
return {
|
||||
"section_count": section_count,
|
||||
"section_types": section_types,
|
||||
"unique_section_types": sorted(set(
|
||||
s.split()[0] if ' ' in s else s for s in section_types
|
||||
)),
|
||||
"lyric_lines": lyric_lines,
|
||||
"total_lines": total_lines,
|
||||
"character_count": char_count,
|
||||
"syllable_range": f"{min_syl}-{max_syl}",
|
||||
"average_syllables": avg_syl,
|
||||
"total_syllables": total_syl,
|
||||
"estimated_duration": f"{minutes}:{seconds:02d}",
|
||||
"estimated_duration_sec": estimated_duration_sec,
|
||||
"total_cliches": total_cliches,
|
||||
"cliche_categories": cliche_categories,
|
||||
"cliche_status": cli_status,
|
||||
"validation_status": val_status,
|
||||
"transformations_applied": transformations,
|
||||
"transformation_descriptions": trans_descriptions,
|
||||
}
|
||||
|
||||
|
||||
def format_markdown(data: dict) -> str:
|
||||
"""Format the summary data as a markdown block."""
|
||||
lines = [
|
||||
"## Transformation Summary",
|
||||
"",
|
||||
f"**Validation Status:** {data['validation_status'].upper()}",
|
||||
f"**Sections:** {data['section_count']} ({', '.join(data['unique_section_types'])})",
|
||||
f"**Lyric Lines:** {data['lyric_lines']}",
|
||||
f"**Syllable Range:** {data['syllable_range']} (avg {data['average_syllables']})",
|
||||
f"**Estimated Duration:** ~{data['estimated_duration']}",
|
||||
]
|
||||
|
||||
if data['character_count'] > 0:
|
||||
lines.append(f"**Character Count:** {data['character_count']}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"**Cliche Detection:** {data['total_cliches']} found ({data['cliche_status']})")
|
||||
if data['cliche_categories']:
|
||||
for cat, count in sorted(data['cliche_categories'].items()):
|
||||
lines.append(f" - {cat}: {count}")
|
||||
|
||||
if data['transformations_applied']:
|
||||
lines.append("")
|
||||
lines.append("**Transformations Applied:**")
|
||||
for desc in data['transformation_descriptions']:
|
||||
lines.append(desc)
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_report(data: dict, markdown: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = []
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"metrics": data,
|
||||
"markdown": markdown,
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": 0,
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Assemble Transformation Summary from validation, syllable, and cliche reports.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --validation val.json --syllables syl.json --cliches cli.json
|
||||
%(prog)s --validation val.json --syllables syl.json --cliches cli.json --transformations "ST,CC,RA"
|
||||
%(prog)s --validation val.json --syllables syl.json --cliches cli.json -o summary.md --verbose
|
||||
|
||||
Exit codes: 0=pass, 1=issues, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("--validation", required=True, help="Path to validate-lyrics.py JSON output")
|
||||
parser.add_argument("--syllables", required=True, help="Path to syllable-counter.py JSON output")
|
||||
parser.add_argument("--cliches", required=True, help="Path to cliche-detector.py JSON output")
|
||||
parser.add_argument("--transformations", default="", help="Comma-separated transformation codes applied")
|
||||
parser.add_argument("--text", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("--stdin", action="store_true", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load input files
|
||||
validation = load_json_file(args.validation)
|
||||
syllables_data = load_json_file(args.syllables)
|
||||
cliches_data = load_json_file(args.cliches)
|
||||
|
||||
if not validation and not syllables_data and not cliches_data:
|
||||
print("Error: Could not load any input JSON files.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
transformations = [c.strip().upper() for c in args.transformations.split(",") if c.strip()] if args.transformations else []
|
||||
|
||||
if args.verbose:
|
||||
print(f"Assembling summary (transformations: {transformations})...", file=sys.stderr)
|
||||
|
||||
data = assemble_summary(validation, syllables_data, cliches_data, transformations)
|
||||
markdown = format_markdown(data)
|
||||
report = build_report(data, markdown, args.skill_path)
|
||||
|
||||
# Decide output format
|
||||
if args.output:
|
||||
out_path = Path(args.output)
|
||||
if out_path.suffix == ".json":
|
||||
out_path.write_text(json.dumps(report, indent=2))
|
||||
else:
|
||||
out_path.write_text(markdown)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(markdown)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
270
.agent/skills/suno-lyric-transformer/scripts/cliche-detector.py
Normal file
270
.agent/skills/suno-lyric-transformer/scripts/cliche-detector.py
Normal file
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Detect cliche phrases in song lyrics.
|
||||
|
||||
Scans lyrics against a curated list of overused songwriting phrases and
|
||||
returns flagged matches with line numbers and suggested alternatives.
|
||||
|
||||
Usage:
|
||||
python cliche-detector.py <lyrics-file> [options]
|
||||
|
||||
# Detect cliches in a file
|
||||
python cliche-detector.py lyrics.txt
|
||||
|
||||
# Detect from text argument
|
||||
python cliche-detector.py --text "Fire in my soul keeps burning bright"
|
||||
|
||||
# Output to file
|
||||
python cliche-detector.py lyrics.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "cliche-detector"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Cliche database: pattern -> category and alternatives
|
||||
# Patterns use word boundaries for accurate matching
|
||||
CLICHES = {
|
||||
# Nature/Weather cliches
|
||||
r"dance\s+in\s+the\s+rain": {
|
||||
"category": "nature",
|
||||
"alternatives": ["stand still in the downpour", "let the storm soak through", "walk the wet streets barefoot"]
|
||||
},
|
||||
r"light\s+(?:in|at)\s+(?:the\s+)?(?:end\s+of\s+the\s+)?tunnel": {
|
||||
"category": "nature",
|
||||
"alternatives": ["a crack in the wall letting day through", "the exit sign still glowing", "morning edging past the blinds"]
|
||||
},
|
||||
r"(?:a|the)\s+storm\s+(?:is\s+)?coming": {
|
||||
"category": "nature",
|
||||
"alternatives": ["the pressure's dropping", "sky's gone that green color", "the stillness before everything moves"]
|
||||
},
|
||||
r"garden\s+grow(?:ing|s)?\s+(?:through|in)\s+(?:the\s+)?rain": {
|
||||
"category": "nature",
|
||||
"alternatives": ["roots pushing through concrete", "something green where nothing should be", "growing sideways toward the light"]
|
||||
},
|
||||
# Fire/Passion cliches
|
||||
r"fire\s+in\s+my\s+soul": {
|
||||
"category": "passion",
|
||||
"alternatives": ["this ache that won't sit still", "something restless under my ribs", "a hum I can't turn off"]
|
||||
},
|
||||
r"burn(?:ing)?\s+(?:bright|inside|with\s+desire)": {
|
||||
"category": "passion",
|
||||
"alternatives": ["glowing like a wire", "running hot and quiet", "lit up from the inside out"]
|
||||
},
|
||||
r"(?:set|light)\s+(?:my|the)\s+world\s+on\s+fire": {
|
||||
"category": "passion",
|
||||
"alternatives": ["rearrange everything I know", "flip the table", "make the ground shake under me"]
|
||||
},
|
||||
r"spark\s+(?:that|which)\s+(?:ignit|light)": {
|
||||
"category": "passion",
|
||||
"alternatives": ["the moment it all shifted", "the first crack in the wall", "when the static cleared"]
|
||||
},
|
||||
# Heart/Emotional cliches
|
||||
r"broken\s+(?:heart|wings|dreams)": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["bent out of shape", "cracked but not split", "the pieces I keep finding"]
|
||||
},
|
||||
r"heart\s+of\s+gold": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["stubborn tenderness", "gentle past the rough", "kind in a way that costs them"]
|
||||
},
|
||||
r"(?:my|your|the)\s+heart\s+(?:is\s+)?(?:beating|racing|pounding)": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["blood drumming in my ears", "chest tight with the rush", "pulse in my fingertips"]
|
||||
},
|
||||
r"tear(?:s)?\s+(?:fall(?:ing)?|roll(?:ing)?)\s+down": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["eyes stinging", "wet face in the mirror", "salt on my lips"]
|
||||
},
|
||||
r"(?:mend|heal|fix)\s+(?:my|your|a)\s+broken\s+heart": {
|
||||
"category": "emotional",
|
||||
"alternatives": ["learn to carry this differently", "stop picking at the wound", "let the scar do its work"]
|
||||
},
|
||||
# Strength/Resilience cliches
|
||||
r"stand(?:ing)?\s+tall": {
|
||||
"category": "strength",
|
||||
"alternatives": ["not flinching", "still here", "planted and refusing to move"]
|
||||
},
|
||||
r"rise\s+(?:from|above|out\s+of)\s+the\s+ashes": {
|
||||
"category": "strength",
|
||||
"alternatives": ["rebuild from the wreckage", "walk out of the rubble", "start with what's left"]
|
||||
},
|
||||
r"(?:light|darkness)\s+(?:in|at)\s+the\s+(?:end|darkest)": {
|
||||
"category": "strength",
|
||||
"alternatives": ["one clear note in all the noise", "a way through I didn't see before", "the moment the fog thins"]
|
||||
},
|
||||
r"never\s+give\s+up": {
|
||||
"category": "strength",
|
||||
"alternatives": ["keep dragging forward", "refuse to quit this", "stubborn enough to stay"]
|
||||
},
|
||||
r"stronger\s+(?:than|now)": {
|
||||
"category": "strength",
|
||||
"alternatives": ["built different now", "tougher in the broken places", "harder to knock down"]
|
||||
},
|
||||
# Love cliches
|
||||
r"you\s+complete\s+me": {
|
||||
"category": "love",
|
||||
"alternatives": ["you fill the gaps I didn't know I had", "with you the noise stops", "I make more sense next to you"]
|
||||
},
|
||||
r"love\s+(?:is\s+)?(?:a\s+)?(?:battlefield|drug|addiction)": {
|
||||
"category": "love",
|
||||
"alternatives": ["love is a habit I can't break", "love is the thing that rearranges the furniture", "love is showing up when it's inconvenient"]
|
||||
},
|
||||
r"(?:my|our)\s+love\s+(?:is\s+)?(?:forever|eternal|undying)": {
|
||||
"category": "love",
|
||||
"alternatives": ["this thing between us doesn't have an off switch", "we keep finding our way back", "stubborn love that won't let go"]
|
||||
},
|
||||
r"lost\s+(?:in|without)\s+(?:your|those)\s+eyes": {
|
||||
"category": "love",
|
||||
"alternatives": ["caught in your attention", "held by the way you look", "frozen when you notice me"]
|
||||
},
|
||||
# Journey/Path cliches
|
||||
r"(?:long|winding)\s+(?:road|path|journey)": {
|
||||
"category": "journey",
|
||||
"alternatives": ["all these miles of wrong turns", "the route that kept changing", "following the bread crumbs"]
|
||||
},
|
||||
r"(?:find|finding|found)\s+(?:my|your|the)\s+way\s+(?:home|back)": {
|
||||
"category": "journey",
|
||||
"alternatives": ["recognize these streets again", "remember where the door is", "follow the familiar sounds"]
|
||||
},
|
||||
r"chasing\s+(?:dreams|the\s+sun|shadows)": {
|
||||
"category": "journey",
|
||||
"alternatives": ["running toward something unnamed", "following the pull", "reaching for what keeps moving"]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_cliches(text: str) -> list[dict]:
|
||||
"""Scan text for cliche phrases and return matches."""
|
||||
findings = []
|
||||
lines = text.split('\n')
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
# Skip metatags
|
||||
if not stripped or re.match(r'^\[.*\]$', stripped):
|
||||
continue
|
||||
|
||||
for pattern, info in CLICHES.items():
|
||||
match = re.search(pattern, stripped, re.IGNORECASE)
|
||||
if match:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "cliche",
|
||||
"location": {"line": i, "column": match.start()},
|
||||
"issue": f"Cliche phrase detected: '{match.group()}'",
|
||||
"fix": f"Consider alternatives: {' | '.join(info['alternatives'])}",
|
||||
"data": {
|
||||
"matched_text": match.group(),
|
||||
"cliche_category": info["category"],
|
||||
"alternatives": info["alternatives"],
|
||||
"full_line": stripped
|
||||
}
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def build_report(findings: list, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if len(findings) > 5:
|
||||
status = "warning"
|
||||
elif len(findings) > 0:
|
||||
status = "info" if len(findings) <= 2 else "warning"
|
||||
|
||||
# Categorize findings
|
||||
categories = {}
|
||||
for f in findings:
|
||||
cat = f.get("data", {}).get("cliche_category", "unknown")
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"total_cliches_found": len(findings),
|
||||
"categories": categories
|
||||
},
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect cliche phrases in song lyrics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s lyrics.txt
|
||||
%(prog)s --text "Fire in my soul keeps burning bright"
|
||||
%(prog)s --stdin < lyrics.txt
|
||||
%(prog)s lyrics.txt -o results.json --verbose
|
||||
|
||||
Exit codes: 0=no cliches, 1=cliches found, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to lyrics text file")
|
||||
parser.add_argument("--text", help="Lyrics text to scan directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read lyrics from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Scanning for cliches ({len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
findings = detect_cliches(text)
|
||||
report = build_report(findings, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if len(findings) == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
248
.agent/skills/suno-lyric-transformer/scripts/lyrics-diff.py
Normal file
248
.agent/skills/suno-lyric-transformer/scripts/lyrics-diff.py
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Produce structured diff between original and transformed lyrics.
|
||||
|
||||
Compares two versions of lyrics and categorizes changes by type (added,
|
||||
removed, modified) and tracks which sections they fall in.
|
||||
|
||||
Usage:
|
||||
python lyrics-diff.py --original orig.txt --transformed trans.txt [options]
|
||||
|
||||
# Compare two files
|
||||
python lyrics-diff.py --original orig.txt --transformed trans.txt
|
||||
|
||||
# Compare two text strings
|
||||
python lyrics-diff.py --original-text "old lyrics" --transformed-text "new lyrics"
|
||||
|
||||
# Output to file
|
||||
python lyrics-diff.py --original orig.txt --transformed trans.txt -o diff.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "lyrics-diff"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
|
||||
def get_section_at_line(lines: list[str], line_idx: int) -> str:
|
||||
"""Determine which section a given line index falls in."""
|
||||
current_section = "(no section)"
|
||||
for i in range(line_idx + 1):
|
||||
if i < len(lines):
|
||||
stripped = lines[i].strip()
|
||||
tag_match = re.match(r'^\[([^\]:]+)\]$', stripped)
|
||||
if tag_match:
|
||||
current_section = tag_match.group(1).strip()
|
||||
return current_section
|
||||
|
||||
|
||||
def compute_diff(original: str, transformed: str) -> dict:
|
||||
"""Compute structured diff between original and transformed lyrics."""
|
||||
orig_lines = original.split('\n')
|
||||
trans_lines = transformed.split('\n')
|
||||
|
||||
matcher = difflib.SequenceMatcher(None, orig_lines, trans_lines)
|
||||
changes = []
|
||||
sections_affected = set()
|
||||
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == 'equal':
|
||||
continue
|
||||
elif tag == 'replace':
|
||||
# Modified lines
|
||||
max_len = max(i2 - i1, j2 - j1)
|
||||
for k in range(max_len):
|
||||
orig_idx = i1 + k if k < (i2 - i1) else None
|
||||
trans_idx = j1 + k if k < (j2 - j1) else None
|
||||
|
||||
if orig_idx is not None and trans_idx is not None:
|
||||
section = get_section_at_line(orig_lines, orig_idx)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "modified",
|
||||
"section": section,
|
||||
"line": orig_idx + 1,
|
||||
"original": orig_lines[orig_idx],
|
||||
"transformed": trans_lines[trans_idx]
|
||||
})
|
||||
elif orig_idx is not None:
|
||||
section = get_section_at_line(orig_lines, orig_idx)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "removed",
|
||||
"section": section,
|
||||
"line": orig_idx + 1,
|
||||
"original": orig_lines[orig_idx],
|
||||
"transformed": ""
|
||||
})
|
||||
elif trans_idx is not None:
|
||||
section = get_section_at_line(trans_lines, trans_idx)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "added",
|
||||
"section": section,
|
||||
"line": trans_idx + 1,
|
||||
"original": "",
|
||||
"transformed": trans_lines[trans_idx]
|
||||
})
|
||||
elif tag == 'delete':
|
||||
for k in range(i1, i2):
|
||||
section = get_section_at_line(orig_lines, k)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "removed",
|
||||
"section": section,
|
||||
"line": k + 1,
|
||||
"original": orig_lines[k],
|
||||
"transformed": ""
|
||||
})
|
||||
elif tag == 'insert':
|
||||
for k in range(j1, j2):
|
||||
section = get_section_at_line(trans_lines, k)
|
||||
sections_affected.add(section)
|
||||
changes.append({
|
||||
"type": "added",
|
||||
"section": section,
|
||||
"line": k + 1,
|
||||
"original": "",
|
||||
"transformed": trans_lines[k]
|
||||
})
|
||||
|
||||
# Generate unified diff for human-readable output
|
||||
unified = list(difflib.unified_diff(
|
||||
orig_lines, trans_lines,
|
||||
fromfile="original", tofile="transformed",
|
||||
lineterm=""
|
||||
))
|
||||
|
||||
summary = {
|
||||
"lines_added": sum(1 for c in changes if c["type"] == "added"),
|
||||
"lines_removed": sum(1 for c in changes if c["type"] == "removed"),
|
||||
"lines_modified": sum(1 for c in changes if c["type"] == "modified"),
|
||||
"sections_affected": sorted(sections_affected)
|
||||
}
|
||||
|
||||
return {
|
||||
"changes": changes,
|
||||
"unified_diff": "\n".join(unified),
|
||||
"summary": summary
|
||||
}
|
||||
|
||||
|
||||
def build_report(result: dict, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
total_changes = len(result["changes"])
|
||||
|
||||
status = "pass"
|
||||
if total_changes == 0:
|
||||
status = "pass"
|
||||
else:
|
||||
status = "info"
|
||||
|
||||
findings = []
|
||||
if total_changes == 0:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "diff",
|
||||
"issue": "No differences found between original and transformed lyrics.",
|
||||
"fix": "Lyrics are identical."
|
||||
})
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"changes": result["changes"],
|
||||
"unified_diff": result["unified_diff"],
|
||||
"summary": result["summary"],
|
||||
"findings": findings,
|
||||
"finding_counts": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Produce structured diff between original and transformed lyrics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --original orig.txt --transformed trans.txt
|
||||
%(prog)s --original-text "old lyrics" --transformed-text "new lyrics"
|
||||
%(prog)s --original orig.txt --transformed trans.txt -o diff.json --verbose
|
||||
|
||||
Exit codes: 0=pass, 1=differences found, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("--original", help="Path to original lyrics file")
|
||||
parser.add_argument("--transformed", help="Path to transformed lyrics file")
|
||||
parser.add_argument("--original-text", help="Original lyrics text directly")
|
||||
parser.add_argument("--transformed-text", help="Transformed lyrics text directly")
|
||||
parser.add_argument("--text", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("--stdin", action="store_true", help="Unused (for pattern consistency)")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
original = ""
|
||||
transformed = ""
|
||||
|
||||
if args.original_text and args.transformed_text:
|
||||
original = args.original_text.replace('\\n', '\n')
|
||||
transformed = args.transformed_text.replace('\\n', '\n')
|
||||
elif args.original and args.transformed:
|
||||
orig_path = Path(args.original)
|
||||
trans_path = Path(args.transformed)
|
||||
if not orig_path.exists():
|
||||
print(f"Error: File not found: {args.original}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if not trans_path.exists():
|
||||
print(f"Error: File not found: {args.transformed}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
original = orig_path.read_text()
|
||||
transformed = trans_path.read_text()
|
||||
else:
|
||||
print("Error: Provide --original and --transformed files, or --original-text and --transformed-text.", file=sys.stderr)
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Comparing lyrics (original: {len(original)} chars, transformed: {len(transformed)} chars)...", file=sys.stderr)
|
||||
|
||||
result = compute_diff(original, transformed)
|
||||
report = build_report(result, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if len(result["changes"]) == 0 else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Check section content lengths against expected ranges from the section-jobs framework.
|
||||
|
||||
Parses lyrics by metatag headers and validates that each section falls within
|
||||
recommended line count ranges for Suno compatibility.
|
||||
|
||||
Usage:
|
||||
python section-length-checker.py <lyrics-file> [options]
|
||||
|
||||
# Check section lengths in a file
|
||||
python section-length-checker.py lyrics.txt
|
||||
|
||||
# Check from text argument
|
||||
python section-length-checker.py --text "[Verse 1]\\nLine one\\nLine two"
|
||||
|
||||
# Output to file
|
||||
python section-length-checker.py lyrics.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "section-length-checker"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Expected line count ranges per section type (min, max)
|
||||
SECTION_RANGES = {
|
||||
"intro": (0, 4),
|
||||
"verse": (4, 8),
|
||||
"pre-chorus": (2, 4),
|
||||
"chorus": (2, 6),
|
||||
"bridge": (2, 6),
|
||||
"breakdown": (2, 4),
|
||||
"build-up": (2, 4),
|
||||
"outro": (2, 6),
|
||||
"hook": (1, 4),
|
||||
"refrain": (2, 6),
|
||||
"interlude": (0, 4),
|
||||
"post-chorus": (2, 4),
|
||||
"solo": (0, 2),
|
||||
"guitar solo": (0, 2),
|
||||
"piano solo": (0, 2),
|
||||
"sax solo": (0, 2),
|
||||
"saxophone solo": (0, 2),
|
||||
"drum solo": (0, 2),
|
||||
"bass solo": (0, 2),
|
||||
"instrumental": (0, 4),
|
||||
"build": (2, 4),
|
||||
"drop": (0, 4),
|
||||
"break": (0, 4),
|
||||
"end": (0, 4),
|
||||
"fade out": (0, 4),
|
||||
"fade in": (0, 4),
|
||||
}
|
||||
|
||||
|
||||
def normalize_section_name(tag: str) -> str:
|
||||
"""Normalize section tag to base name: 'Verse 1' -> 'verse', 'Final Chorus' -> 'chorus'."""
|
||||
tag_lower = tag.lower().strip()
|
||||
# Strip trailing numbers
|
||||
tag_lower = re.sub(r'\s*\d+$', '', tag_lower)
|
||||
# Handle "final chorus" -> "chorus"
|
||||
tag_lower = re.sub(r'^final\s+', '', tag_lower)
|
||||
return tag_lower.strip()
|
||||
|
||||
|
||||
def parse_sections(text: str) -> list[dict]:
|
||||
"""Parse lyrics into sections with line counts."""
|
||||
lines = text.split('\n')
|
||||
sections = []
|
||||
current_section = None
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# Check for section metatag
|
||||
tag_match = re.match(r'^\[([^\]:]+)\]$', stripped)
|
||||
if tag_match:
|
||||
tag_content = tag_match.group(1).strip()
|
||||
# Skip descriptor metatags (contain colon)
|
||||
if ':' in tag_content:
|
||||
continue
|
||||
# Save previous section
|
||||
if current_section is not None:
|
||||
sections.append(current_section)
|
||||
current_section = {
|
||||
"tag": tag_content,
|
||||
"base_name": normalize_section_name(tag_content),
|
||||
"lyric_lines": []
|
||||
}
|
||||
continue
|
||||
|
||||
# Check for descriptor metatags like [Energy: slow] — don't count as content
|
||||
descriptor_match = re.match(r'^\[[^\]]*:[^\]]*\]$', stripped)
|
||||
if descriptor_match:
|
||||
continue
|
||||
|
||||
# Non-empty, non-tag line goes into current section
|
||||
if stripped and current_section is not None:
|
||||
current_section["lyric_lines"].append(stripped)
|
||||
|
||||
# Don't forget last section
|
||||
if current_section is not None:
|
||||
sections.append(current_section)
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
# Genres that get relaxed section length constraints
|
||||
PROG_GENRES = {"prog", "metal", "progressive", "experimental"}
|
||||
|
||||
|
||||
def check_sections(text: str, genre: str = "") -> dict:
|
||||
"""Check section lengths against expected ranges."""
|
||||
sections = parse_sections(text)
|
||||
findings = []
|
||||
section_results = []
|
||||
is_prog = genre.lower() in PROG_GENRES if genre else False
|
||||
|
||||
for section in sections:
|
||||
line_count = len(section["lyric_lines"])
|
||||
base = section["base_name"]
|
||||
expected = SECTION_RANGES.get(base)
|
||||
# In prog/metal mode, double the max for all sections
|
||||
if expected and is_prog:
|
||||
expected = (expected[0], expected[1] * 2)
|
||||
|
||||
result = {
|
||||
"tag": section["tag"],
|
||||
"base_name": base,
|
||||
"line_count": line_count,
|
||||
"expected_range": list(expected) if expected else None,
|
||||
"status": "unknown"
|
||||
}
|
||||
|
||||
if expected is None:
|
||||
result["status"] = "unknown"
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "section-length",
|
||||
"location": {"section": section["tag"]},
|
||||
"issue": f"Section [{section['tag']}] has no defined expected range.",
|
||||
"fix": "This section type is not in the standard range database."
|
||||
})
|
||||
elif line_count < expected[0]:
|
||||
result["status"] = "short"
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "section-length",
|
||||
"location": {"section": section["tag"]},
|
||||
"issue": f"Section [{section['tag']}] is too short: {line_count} lines (expected {expected[0]}-{expected[1]}).",
|
||||
"fix": f"Add {expected[0] - line_count} more line(s) to reach the minimum of {expected[0]}."
|
||||
})
|
||||
elif line_count > expected[1]:
|
||||
result["status"] = "long"
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "section-length",
|
||||
"location": {"section": section["tag"]},
|
||||
"issue": f"Section [{section['tag']}] is too long: {line_count} lines (expected {expected[0]}-{expected[1]}).",
|
||||
"fix": f"Remove {line_count - expected[1]} line(s) to reach the maximum of {expected[1]}."
|
||||
})
|
||||
else:
|
||||
result["status"] = "pass"
|
||||
|
||||
section_results.append(result)
|
||||
|
||||
return {
|
||||
"sections": section_results,
|
||||
"findings": findings
|
||||
}
|
||||
|
||||
|
||||
def build_report(result: dict, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = result["findings"]
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
passed = sum(1 for s in result["sections"] if s["status"] == "pass")
|
||||
failed = sum(1 for s in result["sections"] if s["status"] in ("short", "long"))
|
||||
|
||||
status = "pass"
|
||||
if failed > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"total_sections": len(result["sections"]),
|
||||
"sections_pass": passed,
|
||||
"sections_fail": failed,
|
||||
},
|
||||
"sections": result["sections"],
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check section content lengths against expected ranges.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s lyrics.txt
|
||||
%(prog)s --text "[Verse 1]\\nLine 1\\nLine 2\\nLine 3\\nLine 4"
|
||||
%(prog)s --stdin < lyrics.txt
|
||||
%(prog)s lyrics.txt -o results.json --verbose
|
||||
|
||||
Expected ranges (lines):
|
||||
Intro=0-4, Verse=4-8, Pre-Chorus=2-4, Chorus=2-6,
|
||||
Bridge=2-6, Breakdown=2-4, Build-Up=2-4, Outro=2-6,
|
||||
Hook=1-4, Refrain=2-6
|
||||
|
||||
Exit codes: 0=pass, 1=issues, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to lyrics text file")
|
||||
parser.add_argument("--text", help="Lyrics text to check directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read lyrics from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
parser.add_argument("--genre", default="", help="Genre hint (prog, metal, progressive, experimental) to relax length constraints")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Checking section lengths ({len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
result = check_sections(text, genre=args.genre)
|
||||
report = build_report(result, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
383
.agent/skills/suno-lyric-transformer/scripts/syllable-counter.py
Normal file
383
.agent/skills/suno-lyric-transformer/scripts/syllable-counter.py
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Count syllables per line and analyze rhythmic consistency in lyrics.
|
||||
|
||||
Uses a heuristic syllable counting algorithm (vowel cluster method with
|
||||
common English adjustments). Not perfect, but reliable enough for
|
||||
songwriting guidance — consistent to within +/- 1 syllable per line.
|
||||
|
||||
Usage:
|
||||
python syllable-counter.py <lyrics-file> [options]
|
||||
|
||||
# Count syllables in a file
|
||||
python syllable-counter.py lyrics.txt
|
||||
|
||||
# Count from text argument
|
||||
python syllable-counter.py --text "Walking through the fog of morning"
|
||||
|
||||
# Output to file
|
||||
python syllable-counter.py lyrics.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "syllable-counter"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Common words with known syllable counts that the algorithm gets wrong
|
||||
SYLLABLE_OVERRIDES = {
|
||||
"the": 1, "every": 3, "different": 3, "evening": 3, "heaven": 2,
|
||||
"beautiful": 3, "comfortable": 3, "interesting": 4, "chocolate": 3,
|
||||
"fire": 2, "hour": 2, "flower": 2, "power": 2, "tower": 2,
|
||||
"desire": 3, "inspire": 3, "higher": 2, "liar": 2, "wire": 2,
|
||||
"quiet": 2, "lion": 2, "riot": 2, "diary": 3, "science": 2,
|
||||
"poem": 2, "being": 2, "seeing": 2, "doing": 2, "going": 2,
|
||||
"cruel": 2, "fuel": 2, "jewel": 2, "real": 1, "deal": 1,
|
||||
"people": 2, "little": 2, "middle": 2, "simple": 2, "able": 2,
|
||||
"maybe": 2, "somewhere": 2, "nowhere": 2, "everywhere": 3,
|
||||
"i'm": 1, "you're": 1, "we're": 1, "they're": 1, "he's": 1,
|
||||
"she's": 1, "it's": 1, "don't": 1, "won't": 1, "can't": 1,
|
||||
"couldn't": 2, "wouldn't": 2, "shouldn't": 2, "didn't": 2,
|
||||
"isn't": 2, "wasn't": 2, "aren't": 2, "weren't": 2,
|
||||
}
|
||||
|
||||
|
||||
def count_syllables(word: str) -> int:
|
||||
"""Count syllables in a single word using vowel cluster heuristic."""
|
||||
word = word.lower().strip()
|
||||
|
||||
# Remove non-alpha except apostrophes
|
||||
word = re.sub(r"[^a-z']", "", word)
|
||||
|
||||
if not word:
|
||||
return 0
|
||||
|
||||
# Check overrides first
|
||||
if word in SYLLABLE_OVERRIDES:
|
||||
return SYLLABLE_OVERRIDES[word]
|
||||
|
||||
# Vowel cluster counting with adjustments
|
||||
vowels = "aeiouy"
|
||||
count = 0
|
||||
prev_vowel = False
|
||||
|
||||
for i, char in enumerate(word):
|
||||
is_vowel = char in vowels
|
||||
if is_vowel and not prev_vowel:
|
||||
count += 1
|
||||
prev_vowel = is_vowel
|
||||
|
||||
# Adjustments
|
||||
# Silent e at end
|
||||
if word.endswith('e') and not word.endswith(('le', 'ce', 'se', 'ge', 'ze', 'ne', 'me', 've', 'te', 'de', 'be', 'fe', 'he', 'ke', 'pe', 'we', 'ye')):
|
||||
count -= 1
|
||||
elif word.endswith('e') and len(word) > 3 and word[-2] not in vowels:
|
||||
count -= 1
|
||||
|
||||
# -ed ending (usually not a syllable unless preceded by t or d)
|
||||
if word.endswith('ed') and len(word) > 3:
|
||||
if word[-3] not in ('t', 'd'):
|
||||
count -= 1
|
||||
|
||||
# -le at end is usually a syllable
|
||||
if word.endswith('le') and len(word) > 2 and word[-3] not in vowels:
|
||||
count += 1
|
||||
|
||||
# -es ending
|
||||
if word.endswith('es') and len(word) > 3:
|
||||
if word[-3] in ('s', 'z', 'x', 'ch', 'sh'):
|
||||
pass # -es IS a syllable here
|
||||
elif word[-3] not in vowels:
|
||||
count -= 1
|
||||
|
||||
# Ensure at least 1 syllable for any word
|
||||
return max(1, count)
|
||||
|
||||
|
||||
def count_line_syllables(line: str) -> int:
|
||||
"""Count total syllables in a line of text."""
|
||||
# Remove metatags
|
||||
line = re.sub(r'\[.*?\]', '', line)
|
||||
words = line.split()
|
||||
return sum(count_syllables(w) for w in words)
|
||||
|
||||
|
||||
def estimate_duration(total_lines: int, avg_syllables: float, sections: list = None) -> tuple:
|
||||
"""Estimate song duration based on lyrics structure and instrumental sections.
|
||||
|
||||
Returns (min_seconds, max_seconds) tuple.
|
||||
|
||||
Factors:
|
||||
- Lyric lines: ~3-5 seconds per line depending on syllable density
|
||||
- Instrumental sections (Intro, Outro, Solo, Breakdown, Build-Up):
|
||||
add time with no lyric lines
|
||||
- Suno typically generates 2-4 min songs from moderate lyrics
|
||||
|
||||
NOTE: This is a rough estimate. Actual Suno output varies significantly
|
||||
based on tempo, model, style prompt, and generation randomness.
|
||||
"""
|
||||
if total_lines == 0:
|
||||
return (0, 0)
|
||||
|
||||
# Base time from lyric lines
|
||||
# Denser syllables = faster delivery = less time per line
|
||||
if avg_syllables > 10:
|
||||
secs_per_line_min, secs_per_line_max = 2.5, 4.0
|
||||
elif avg_syllables > 7:
|
||||
secs_per_line_min, secs_per_line_max = 3.0, 4.5
|
||||
else:
|
||||
secs_per_line_min, secs_per_line_max = 3.5, 5.5
|
||||
|
||||
lyric_min = round(total_lines * secs_per_line_min)
|
||||
lyric_max = round(total_lines * secs_per_line_max)
|
||||
|
||||
# Add time for instrumental sections
|
||||
# These appear as section tags but contribute no lyric lines
|
||||
INSTRUMENTAL_TAGS = {
|
||||
"intro": (5, 15),
|
||||
"outro": (8, 20),
|
||||
"guitar solo": (10, 25),
|
||||
"solo": (10, 25),
|
||||
"instrumental": (10, 25),
|
||||
"breakdown": (8, 20),
|
||||
"build-up": (5, 15),
|
||||
"interlude": (8, 20),
|
||||
"drum solo": (8, 20),
|
||||
"sax solo": (10, 25),
|
||||
"piano solo": (10, 25),
|
||||
}
|
||||
|
||||
instrumental_min = 0
|
||||
instrumental_max = 0
|
||||
if sections:
|
||||
for section in sections:
|
||||
section_name = section.get("name", "").strip("[]").lower()
|
||||
for tag, (t_min, t_max) in INSTRUMENTAL_TAGS.items():
|
||||
if tag in section_name:
|
||||
instrumental_min += t_min
|
||||
instrumental_max += t_max
|
||||
break
|
||||
|
||||
# Also check for [Hummed] or empty-content sections that still take time
|
||||
if sections:
|
||||
for section in sections:
|
||||
section_name = section.get("name", "").strip("[]").lower()
|
||||
if "hummed" in section_name or "whistled" in section_name:
|
||||
instrumental_min += 5
|
||||
instrumental_max += 15
|
||||
|
||||
min_seconds = lyric_min + instrumental_min
|
||||
max_seconds = lyric_max + instrumental_max
|
||||
|
||||
return (min_seconds, max_seconds)
|
||||
|
||||
|
||||
def format_duration(seconds: int) -> str:
|
||||
"""Format seconds as M:SS."""
|
||||
minutes = seconds // 60
|
||||
secs = seconds % 60
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
|
||||
def format_duration_range(min_seconds: int, max_seconds: int) -> str:
|
||||
"""Format a duration range as 'M:SS-M:SS'."""
|
||||
return f"{format_duration(min_seconds)}-{format_duration(max_seconds)}"
|
||||
|
||||
|
||||
def analyze_lyrics(text: str) -> dict:
|
||||
"""Analyze lyrics for syllable counts and rhythmic consistency."""
|
||||
lines = text.split('\n')
|
||||
line_data = []
|
||||
sections = []
|
||||
current_section = {"name": "ungrouped", "lines": []}
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
|
||||
# Check for section tag
|
||||
tag_match = re.match(r'^\[([^\]:]+?)(?:\s*\d*)?\]$', stripped)
|
||||
if tag_match and ':' not in stripped:
|
||||
# Start new section
|
||||
if current_section["lines"]:
|
||||
sections.append(current_section)
|
||||
current_section = {"name": stripped, "lines": []}
|
||||
continue
|
||||
|
||||
# Skip empty lines and descriptor metatags
|
||||
if not stripped or re.match(r'^\[.*:.*\]$', stripped):
|
||||
continue
|
||||
|
||||
syllables = count_line_syllables(stripped)
|
||||
entry = {
|
||||
"line_number": i,
|
||||
"text": stripped,
|
||||
"syllables": syllables,
|
||||
"word_count": len(stripped.split())
|
||||
}
|
||||
line_data.append(entry)
|
||||
current_section["lines"].append(entry)
|
||||
|
||||
# Don't forget last section
|
||||
if current_section["lines"]:
|
||||
sections.append(current_section)
|
||||
|
||||
# Analyze per-section consistency
|
||||
section_analysis = []
|
||||
findings = []
|
||||
|
||||
for section in sections:
|
||||
if not section["lines"]:
|
||||
continue
|
||||
|
||||
counts = [line["syllables"] for line in section["lines"]]
|
||||
avg = sum(counts) / len(counts)
|
||||
min_c = min(counts)
|
||||
max_c = max(counts)
|
||||
spread = max_c - min_c
|
||||
|
||||
analysis = {
|
||||
"section": section["name"],
|
||||
"line_count": len(counts),
|
||||
"syllable_counts": counts,
|
||||
"average": round(avg, 1),
|
||||
"min": min_c,
|
||||
"max": max_c,
|
||||
"spread": spread
|
||||
}
|
||||
section_analysis.append(analysis)
|
||||
|
||||
# Flag high variance within a section (spread > 2x the average line)
|
||||
if spread > avg and len(counts) > 2:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "rhythm",
|
||||
"location": {"section": section["name"]},
|
||||
"issue": f"High syllable variance in {section['name']}: range {min_c}-{max_c} (avg {avg:.0f}). This may cause uneven vocal phrasing.",
|
||||
"fix": f"Try to keep lines within a {int(avg)-2}-{int(avg)+2} syllable range for smoother singing.",
|
||||
"data": {"section": section["name"], "counts": counts, "average": round(avg, 1)}
|
||||
})
|
||||
|
||||
# Overall metrics
|
||||
all_counts = [entry["syllables"] for entry in line_data]
|
||||
overall_avg = sum(all_counts) / len(all_counts) if all_counts else 0
|
||||
|
||||
# Duration estimation (accounts for instrumental sections)
|
||||
min_sec, max_sec = estimate_duration(len(line_data), overall_avg, sections)
|
||||
duration_info = {
|
||||
"min_seconds": min_sec,
|
||||
"max_seconds": max_sec,
|
||||
"formatted": format_duration_range(min_sec, max_sec),
|
||||
"note": "Rough estimate — actual Suno output varies based on tempo, model, style prompt, and generation randomness. Instrumental sections, solos, and intros/outros add time beyond what lyrics alone suggest."
|
||||
}
|
||||
|
||||
return {
|
||||
"line_data": line_data,
|
||||
"section_analysis": section_analysis,
|
||||
"overall": {
|
||||
"total_lyric_lines": len(line_data),
|
||||
"total_syllables": sum(all_counts),
|
||||
"average_syllables_per_line": round(overall_avg, 1),
|
||||
"min_syllables": min(all_counts) if all_counts else 0,
|
||||
"max_syllables": max(all_counts) if all_counts else 0,
|
||||
"estimated_duration": duration_info
|
||||
},
|
||||
"findings": findings
|
||||
}
|
||||
|
||||
|
||||
def build_report(analysis: dict, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = analysis["findings"]
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["high"] > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": analysis["overall"],
|
||||
"line_data": analysis["line_data"],
|
||||
"section_analysis": analysis["section_analysis"],
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Count syllables per line and analyze rhythmic consistency in lyrics.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s lyrics.txt
|
||||
%(prog)s --text "Walking through the fog of morning"
|
||||
%(prog)s --stdin < lyrics.txt
|
||||
%(prog)s lyrics.txt -o results.json --verbose
|
||||
|
||||
Exit codes: 0=pass, 1=rhythm issues found, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to lyrics text file")
|
||||
parser.add_argument("--text", help="Lyrics text to analyze directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read lyrics from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
parser.add_argument("--estimate-duration", action="store_true", help="Show estimated duration prominently")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Analyzing syllables ({len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
analysis = analyze_lyrics(text)
|
||||
report = build_report(analysis, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Configure pytest to collect test files with hyphens in names."""
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_collect_file(parent, file_path):
|
||||
if file_path.suffix == ".py" and file_path.name.startswith("test-"):
|
||||
return pytest.Module.from_parent(parent, path=file_path)
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for analyze-input.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "analyze-input.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestAnalyzeInput:
|
||||
def test_basic_metrics(self):
|
||||
text = "Hello world\nThis is a test\nThree lines here"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["line_count"] == 3
|
||||
assert m["non_empty_line_count"] == 3
|
||||
assert m["word_count"] == 9
|
||||
assert m["character_count"] > 0
|
||||
|
||||
def test_detects_existing_structure(self):
|
||||
text = "[Verse 1]\nSome lyrics here\nMore lyrics\n\n[Chorus]\nChorus line"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["has_existing_structure"] is True
|
||||
assert "Verse 1" in m["existing_tags"]
|
||||
assert "Chorus" in m["existing_tags"]
|
||||
|
||||
def test_no_structure_detected(self):
|
||||
text = "Just raw text\nWith no brackets\nPlain poetry"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["has_existing_structure"] is False
|
||||
assert m["existing_tags"] == []
|
||||
|
||||
def test_repeated_phrases(self):
|
||||
text = "come back to me tonight\nwhen the stars are bright\ncome back to me tonight\nunder the pale moonlight"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
phrases = [p["phrase"] for p in m["repeated_phrases"]]
|
||||
assert any("come back to me" in p for p in phrases)
|
||||
|
||||
def test_rhyme_pairs(self):
|
||||
text = "Walking down the street\nFeeling the beat\nLooking for the light\nShining in the night"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
rhymes = m["potential_rhyme_pairs"]
|
||||
rhyme_words = [set(r["words"]) for r in rhymes]
|
||||
assert any({"street", "beat"} == w for w in rhyme_words) or any({"light", "night"} == w for w in rhyme_words)
|
||||
|
||||
def test_short_structure_estimate(self):
|
||||
text = "\n".join(f"Line {i}" for i in range(1, 10))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "short"
|
||||
|
||||
def test_medium_structure_estimate(self):
|
||||
text = "\n".join(f"Line number {i} of the song" for i in range(1, 25))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "medium"
|
||||
|
||||
def test_long_structure_estimate(self):
|
||||
text = "\n".join(f"Line number {i} of a very long song" for i in range(1, 35))
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
m = report["metrics"]
|
||||
assert m["estimated_structure"] == "long"
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--text", "Some text")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "analyze" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for assemble-summary.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "assemble-summary.py")
|
||||
|
||||
|
||||
def run_script(*args, input_data=None):
|
||||
"""Run the script and return stdout and returncode."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True,
|
||||
input=input_data
|
||||
)
|
||||
return result.stdout, result.returncode
|
||||
|
||||
|
||||
def create_test_files(tmp_path):
|
||||
"""Create sample JSON input files for testing."""
|
||||
validation = {
|
||||
"script": "validate-lyrics",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_lines": 20,
|
||||
"lyric_lines": 14,
|
||||
"section_count": 4,
|
||||
"sections": ["Verse 1", "Chorus", "Verse 2", "Chorus"]
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0}
|
||||
}
|
||||
|
||||
syllables = {
|
||||
"script": "syllable-counter",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_lyric_lines": 14,
|
||||
"total_syllables": 112,
|
||||
"average_syllables_per_line": 8.0,
|
||||
"min_syllables": 5,
|
||||
"max_syllables": 12
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 0}
|
||||
}
|
||||
|
||||
cliches = {
|
||||
"script": "cliche-detector",
|
||||
"status": "pass",
|
||||
"metrics": {
|
||||
"total_cliches_found": 2,
|
||||
"categories": {"emotional": 1, "nature": 1}
|
||||
},
|
||||
"findings": [],
|
||||
"summary": {"total": 2}
|
||||
}
|
||||
|
||||
val_file = tmp_path / "validation.json"
|
||||
syl_file = tmp_path / "syllables.json"
|
||||
cli_file = tmp_path / "cliches.json"
|
||||
|
||||
val_file.write_text(json.dumps(validation))
|
||||
syl_file.write_text(json.dumps(syllables))
|
||||
cli_file.write_text(json.dumps(cliches))
|
||||
|
||||
return str(val_file), str(syl_file), str(cli_file)
|
||||
|
||||
|
||||
class TestAssembleSummary:
|
||||
def test_basic_assembly(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "Transformation Summary" in output
|
||||
assert "Validation Status" in output
|
||||
assert "Sections:" in output
|
||||
|
||||
def test_with_transformations(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"--transformations", "ST,CC,RA"
|
||||
)
|
||||
assert code == 0
|
||||
assert "Transformations Applied" in output
|
||||
assert "ST:" in output
|
||||
assert "CC:" in output
|
||||
assert "RA:" in output
|
||||
|
||||
def test_json_output(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
out_file = tmp_path / "output.json"
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"-o", str(out_file)
|
||||
)
|
||||
assert code == 0
|
||||
report = json.loads(out_file.read_text())
|
||||
assert report["script"] == "assemble-summary"
|
||||
assert "metrics" in report
|
||||
assert "markdown" in report
|
||||
|
||||
def test_markdown_output_file(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
out_file = tmp_path / "output.md"
|
||||
output, code = run_script(
|
||||
"--validation", val, "--syllables", syl, "--cliches", cli,
|
||||
"-o", str(out_file)
|
||||
)
|
||||
assert code == 0
|
||||
content = out_file.read_text()
|
||||
assert "## Transformation Summary" in content
|
||||
|
||||
def test_cliche_categories_displayed(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "2 found" in output
|
||||
assert "emotional" in output
|
||||
assert "nature" in output
|
||||
|
||||
def test_syllable_range_displayed(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
assert "5-12" in output
|
||||
assert "avg 8.0" in output
|
||||
|
||||
def test_estimated_duration(self, tmp_path):
|
||||
val, syl, cli = create_test_files(tmp_path)
|
||||
output, code = run_script("--validation", val, "--syllables", syl, "--cliches", cli)
|
||||
assert code == 0
|
||||
# 4 sections * 15 sec = 60 sec = 1:00
|
||||
assert "1:00" in output
|
||||
|
||||
def test_missing_files_handled(self, tmp_path):
|
||||
missing = str(tmp_path / "nonexistent.json")
|
||||
output, code = run_script(
|
||||
"--validation", missing, "--syllables", missing, "--cliches", missing
|
||||
)
|
||||
assert code == 2
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "assemble" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for cliche-detector.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "cliche-detector.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestClicheDetector:
|
||||
def test_detects_fire_in_soul(self):
|
||||
report, code = run_script("--text", "There's a fire in my soul tonight")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
assert any("fire in my soul" in f["data"]["matched_text"] for f in report["findings"])
|
||||
|
||||
def test_detects_dance_in_rain(self):
|
||||
report, code = run_script("--text", "We'll dance in the rain together")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_detects_broken_heart(self):
|
||||
report, code = run_script("--text", "My broken heart won't heal")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_detects_stand_tall(self):
|
||||
report, code = run_script("--text", "I'm standing tall against the wind")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_no_cliches_in_clean_text(self):
|
||||
report, code = run_script("--text", "The kitchen table holds three plates\nSteam rising from the coffee cup")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] == 0
|
||||
assert code == 0
|
||||
|
||||
def test_skips_metatags(self):
|
||||
text = "[Verse 1]\nFire in my soul\n[Chorus]\nClean lyrics here"
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
# Should find the cliche in the lyric line, not in metatags
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_provides_alternatives(self):
|
||||
report, code = run_script("--text", "Rise from the ashes of what we were")
|
||||
assert report is not None
|
||||
assert len(report["findings"]) > 0
|
||||
finding = report["findings"][0]
|
||||
assert "alternatives" in finding["data"]
|
||||
assert len(finding["data"]["alternatives"]) > 0
|
||||
|
||||
def test_multiple_cliches_in_one_text(self):
|
||||
text = (
|
||||
"Fire in my soul keeps burning bright\n"
|
||||
"Standing tall through broken dreams\n"
|
||||
"Dance in the rain with a heart of gold\n"
|
||||
)
|
||||
report, code = run_script("--text", text)
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 3
|
||||
|
||||
def test_case_insensitive(self):
|
||||
report, code = run_script("--text", "FIRE IN MY SOUL")
|
||||
assert report is not None
|
||||
assert report["metrics"]["total_cliches_found"] >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--text", "Just a normal line")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "cliche" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for lyrics-diff.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "lyrics-diff.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestLyricsDiff:
|
||||
def test_identical_lyrics(self):
|
||||
text = "[Verse 1]\nHello world\nGoodbye moon"
|
||||
report, code = run_script("--original-text", text, "--transformed-text", text)
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert len(report["changes"]) == 0
|
||||
assert report["summary"]["lines_added"] == 0
|
||||
assert report["summary"]["lines_removed"] == 0
|
||||
assert report["summary"]["lines_modified"] == 0
|
||||
|
||||
def test_modified_line(self):
|
||||
original = "[Verse 1]\nWalking through the rain"
|
||||
transformed = "[Verse 1]\nRunning through the storm"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert report["status"] == "info"
|
||||
modified = [c for c in report["changes"] if c["type"] == "modified"]
|
||||
assert len(modified) >= 1
|
||||
assert report["summary"]["lines_modified"] >= 1
|
||||
|
||||
def test_added_lines(self):
|
||||
original = "[Verse 1]\nLine one"
|
||||
transformed = "[Verse 1]\nLine one\nLine two\nLine three"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
added = [c for c in report["changes"] if c["type"] == "added"]
|
||||
assert len(added) >= 1
|
||||
assert report["summary"]["lines_added"] >= 1
|
||||
|
||||
def test_removed_lines(self):
|
||||
original = "[Verse 1]\nLine one\nLine two\nLine three"
|
||||
transformed = "[Verse 1]\nLine one"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
removed = [c for c in report["changes"] if c["type"] == "removed"]
|
||||
assert len(removed) >= 1
|
||||
assert report["summary"]["lines_removed"] >= 1
|
||||
|
||||
def test_section_tracking(self):
|
||||
original = "[Verse 1]\nOld verse line\n\n[Chorus]\nOld chorus line"
|
||||
transformed = "[Verse 1]\nNew verse line\n\n[Chorus]\nNew chorus line"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert len(report["summary"]["sections_affected"]) >= 1
|
||||
|
||||
def test_unified_diff_output(self):
|
||||
original = "[Verse 1]\nHello"
|
||||
transformed = "[Verse 1]\nGoodbye"
|
||||
report, code = run_script("--original-text", original, "--transformed-text", transformed)
|
||||
assert report is not None
|
||||
assert "unified_diff" in report
|
||||
assert len(report["unified_diff"]) > 0
|
||||
|
||||
def test_file_input(self, tmp_path):
|
||||
orig_file = tmp_path / "orig.txt"
|
||||
trans_file = tmp_path / "trans.txt"
|
||||
orig_file.write_text("[Verse 1]\nOriginal line")
|
||||
trans_file.write_text("[Verse 1]\nTransformed line")
|
||||
report, code = run_script("--original", str(orig_file), "--transformed", str(trans_file))
|
||||
assert report is not None
|
||||
assert len(report["changes"]) >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("--original-text", "a", "--transformed-text", "b")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "changes" in report
|
||||
assert "summary" in report
|
||||
assert "unified_diff" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "diff" in result.stdout.lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for section-length-checker.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "section-length-checker.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestSectionLengthChecker:
|
||||
def test_sections_within_range(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Line one of the verse\n"
|
||||
"Line two of the verse\n"
|
||||
"Line three of the verse\n"
|
||||
"Line four of the verse\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus line one\n"
|
||||
"Chorus line two\n"
|
||||
"Chorus line three\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert report["metrics"]["sections_pass"] == 2
|
||||
assert report["metrics"]["sections_fail"] == 0
|
||||
|
||||
def test_verse_too_short(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Only one line\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus one\n"
|
||||
"Chorus two\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["status"] == "warning"
|
||||
short_sections = [s for s in report["sections"] if s["status"] == "short"]
|
||||
assert len(short_sections) >= 1
|
||||
assert short_sections[0]["base_name"] == "verse"
|
||||
|
||||
def test_verse_too_long(self):
|
||||
lyrics = "[Verse 1]\n" + "\n".join(f"Line {i}" for i in range(1, 12)) + "\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
long_sections = [s for s in report["sections"] if s["status"] == "long"]
|
||||
assert len(long_sections) >= 1
|
||||
|
||||
def test_intro_can_be_empty(self):
|
||||
lyrics = (
|
||||
"[Intro]\n"
|
||||
"\n"
|
||||
"[Verse 1]\n"
|
||||
"Line one\nLine two\nLine three\nLine four\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
intro = [s for s in report["sections"] if s["base_name"] == "intro"]
|
||||
assert len(intro) == 1
|
||||
assert intro[0]["status"] == "pass"
|
||||
|
||||
def test_numbered_sections_normalized(self):
|
||||
lyrics = (
|
||||
"[Verse 2]\n"
|
||||
"Line one\nLine two\nLine three\nLine four\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Chorus one\nChorus two\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
verse = [s for s in report["sections"] if s["tag"] == "Verse 2"]
|
||||
assert len(verse) == 1
|
||||
assert verse[0]["base_name"] == "verse"
|
||||
|
||||
def test_unknown_section_type(self):
|
||||
lyrics = "[Spoken Word]\nSome content\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
unknown = [s for s in report["sections"] if s["status"] == "unknown"]
|
||||
assert len(unknown) >= 1
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nLine one\nLine two\nLine three\nLine four\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "sections" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "section" in result.stdout.lower()
|
||||
|
||||
def test_descriptor_metatags_not_counted_as_content(self):
|
||||
"""Descriptor metatags like [Energy: slow] should not inflate line counts."""
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"[Energy: slow]\n"
|
||||
"[Vocal Style: clean]\n"
|
||||
"[Mood: dark]\n"
|
||||
"Line one of the verse\n"
|
||||
"Line two of the verse\n"
|
||||
"Line three of the verse\n"
|
||||
"Line four of the verse\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
verse = [s for s in report["sections"] if s["base_name"] == "verse"]
|
||||
assert len(verse) == 1
|
||||
# Should count only 4 lyric lines, not 7
|
||||
assert verse[0]["line_count"] == 4
|
||||
assert verse[0]["status"] == "pass"
|
||||
|
||||
def test_prog_genre_relaxes_verse_limit(self):
|
||||
"""With --genre prog, verses can have up to 16 lines without warning."""
|
||||
lines = "\n".join(f"Line {i}" for i in range(1, 13))
|
||||
lyrics = f"[Verse 1]\n{lines}\n"
|
||||
# Without genre flag, 12 lines should be too long (max 8)
|
||||
report_normal, _ = run_script("--text", lyrics)
|
||||
assert report_normal is not None
|
||||
verse_normal = [s for s in report_normal["sections"] if s["base_name"] == "verse"]
|
||||
assert verse_normal[0]["status"] == "long"
|
||||
|
||||
# With prog genre, 12 lines should pass (max becomes 16)
|
||||
report_prog, _ = run_script("--text", lyrics, "--genre", "prog")
|
||||
assert report_prog is not None
|
||||
verse_prog = [s for s in report_prog["sections"] if s["base_name"] == "verse"]
|
||||
assert verse_prog[0]["status"] == "pass"
|
||||
|
||||
def test_interlude_is_known_section(self):
|
||||
"""Interlude should now be a known section type with defined range."""
|
||||
lyrics = "[Interlude]\nSome content\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
interlude = [s for s in report["sections"] if s["base_name"] == "interlude"]
|
||||
assert len(interlude) == 1
|
||||
assert interlude[0]["status"] == "pass"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for syllable-counter.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "syllable-counter.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
# Also test the count_syllables function directly
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location("syllable_counter", Path(__file__).parent.parent / "syllable-counter.py")
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
count_syllables = _mod.count_syllables
|
||||
estimate_duration = _mod.estimate_duration
|
||||
format_duration_range = _mod.format_duration_range
|
||||
|
||||
|
||||
class TestSyllableCounting:
|
||||
"""Test individual word syllable counting."""
|
||||
|
||||
def test_one_syllable_words(self):
|
||||
for word in ["cat", "dog", "the", "run", "light", "dream"]:
|
||||
assert count_syllables(word) == 1, f"Expected 1 syllable for '{word}', got {count_syllables(word)}"
|
||||
|
||||
def test_two_syllable_words(self):
|
||||
for word in ["hello", "window", "walking", "morning", "shadow"]:
|
||||
assert count_syllables(word) == 2, f"Expected 2 syllables for '{word}', got {count_syllables(word)}"
|
||||
|
||||
def test_three_syllable_words(self):
|
||||
for word in ["beautiful", "another", "everyone", "different"]:
|
||||
result = count_syllables(word)
|
||||
assert result == 3, f"Expected 3 syllables for '{word}', got {result}"
|
||||
|
||||
def test_contractions(self):
|
||||
assert count_syllables("I'm") == 1
|
||||
assert count_syllables("don't") == 1
|
||||
assert count_syllables("couldn't") == 2
|
||||
|
||||
def test_empty_string(self):
|
||||
assert count_syllables("") == 0
|
||||
|
||||
|
||||
class TestLyricsAnalysis:
|
||||
"""Test full lyrics analysis via the script."""
|
||||
|
||||
def test_basic_analysis(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["script"] == "syllable-counter"
|
||||
assert report["metrics"]["total_lyric_lines"] == 2
|
||||
assert report["metrics"]["total_syllables"] > 0
|
||||
|
||||
def test_section_grouping(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Short line here\n"
|
||||
"Another short one\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"The chorus comes in strong and bold\n"
|
||||
"With longer lines that carry more weight\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert len(report["section_analysis"]) == 2
|
||||
section_names = [s["section"] for s in report["section_analysis"]]
|
||||
assert "[Verse 1]" in section_names
|
||||
assert "[Chorus]" in section_names
|
||||
|
||||
def test_line_data_includes_syllables(self):
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert len(report["line_data"]) == 1
|
||||
assert "syllables" in report["line_data"][0]
|
||||
assert report["line_data"][0]["syllables"] > 0
|
||||
|
||||
def test_skips_metatags(self):
|
||||
lyrics = "[Mood: haunting]\n[Verse 1]\nWalking through fog\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Only the lyric line should be counted, not metatags
|
||||
assert report["metrics"]["total_lyric_lines"] == 1
|
||||
|
||||
def test_high_variance_warning(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Hi\n"
|
||||
"This is a much longer line with many more syllables than the first\n"
|
||||
"Short\n"
|
||||
"Another really long line that goes on and on and on\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Should flag high syllable variance
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("variance" in i.lower() or "syllable" in i.lower() for i in issues)
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nA simple test line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "line_data" in report
|
||||
assert "section_analysis" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "syllable" in result.stdout.lower()
|
||||
|
||||
|
||||
class TestDurationEstimation:
|
||||
"""Test duration estimation function."""
|
||||
|
||||
def test_zero_lines(self):
|
||||
min_s, max_s = estimate_duration(0, 0)
|
||||
assert min_s == 0
|
||||
assert max_s == 0
|
||||
|
||||
def test_one_line(self):
|
||||
# 7.0 avg syllables = mid range (3.0-4.5 secs/line)
|
||||
min_s, max_s = estimate_duration(1, 7.0)
|
||||
assert min_s == round(1 * 3.5) # low-density range
|
||||
assert max_s == round(1 * 5.5)
|
||||
|
||||
def test_typical_song(self):
|
||||
# 20 lines at 7.0 avg syllables (mid range)
|
||||
min_s, max_s = estimate_duration(20, 7.0)
|
||||
assert min_s == round(20 * 3.5) # 70
|
||||
assert max_s == round(20 * 5.5) # 110
|
||||
|
||||
def test_high_density_faster(self):
|
||||
# High syllable density = faster delivery = less time per line
|
||||
min_s, max_s = estimate_duration(20, 12.0)
|
||||
assert min_s == round(20 * 2.5) # 50
|
||||
assert max_s == round(20 * 4.0) # 80
|
||||
|
||||
def test_instrumental_sections_add_time(self):
|
||||
# Sections with instrumental tags add time
|
||||
sections = [
|
||||
{"name": "[Intro]", "lines": []},
|
||||
{"name": "[Verse]", "lines": [{"syllables": 7}] * 4},
|
||||
{"name": "[Guitar Solo]", "lines": []},
|
||||
{"name": "[Outro]", "lines": []},
|
||||
]
|
||||
min_s, max_s = estimate_duration(4, 7.0, sections)
|
||||
# 4 lines at mid range + intro (5-15) + guitar solo (10-25) + outro (8-20)
|
||||
assert min_s > round(4 * 3.5) # More than just lyrics
|
||||
assert max_s > round(4 * 5.5)
|
||||
|
||||
def test_formatted_range(self):
|
||||
formatted = format_duration_range(50, 90)
|
||||
assert formatted == "0:50-1:30"
|
||||
|
||||
def test_formatted_range_zero(self):
|
||||
formatted = format_duration_range(0, 0)
|
||||
assert formatted == "0:00-0:00"
|
||||
|
||||
def test_formatted_range_large(self):
|
||||
formatted = format_duration_range(120, 240)
|
||||
assert formatted == "2:00-4:00"
|
||||
|
||||
def test_duration_in_report(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
duration = report["metrics"]["estimated_duration"]
|
||||
assert "min_seconds" in duration
|
||||
assert "max_seconds" in duration
|
||||
assert "formatted" in duration
|
||||
assert duration["min_seconds"] > 0
|
||||
assert duration["max_seconds"] > duration["min_seconds"]
|
||||
# Check formatted string pattern M:SS-M:SS
|
||||
assert "-" in duration["formatted"]
|
||||
|
||||
def test_estimate_duration_flag(self):
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics, "--estimate-duration")
|
||||
assert report is not None
|
||||
assert "estimated_duration" in report["metrics"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for validate-lyrics.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "validate-lyrics.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestValidateLyrics:
|
||||
def test_valid_structured_lyrics(self):
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
"\n"
|
||||
"[Verse 2]\n"
|
||||
"Fingerprints on frosted glass\n"
|
||||
"Letters folded into cranes\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert report["script"] == "validate-lyrics"
|
||||
assert report["metrics"]["section_count"] == 4
|
||||
assert "Verse 1" in report["metrics"]["sections"]
|
||||
assert "Chorus" in report["metrics"]["sections"]
|
||||
|
||||
def test_no_section_tags(self):
|
||||
lyrics = "Just some raw text\nWith no structure at all\nThree lines of poetry"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("No section metatags" in i for i in issues)
|
||||
|
||||
def test_style_cue_contamination(self):
|
||||
lyrics = "[Verse 1]\nThe punchy drums echo through my mind\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("style cue" in i.lower() for i in issues)
|
||||
|
||||
def test_asterisk_detection(self):
|
||||
lyrics = "[Verse 1]\n*This line has asterisks*\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Asterisk" in i for i in issues)
|
||||
|
||||
def test_empty_lyrics(self):
|
||||
report, code = run_script("--text", "")
|
||||
assert report is not None
|
||||
assert report["status"] == "fail"
|
||||
assert any(f["severity"] == "critical" for f in report["findings"])
|
||||
|
||||
def test_unrecognized_metatag(self):
|
||||
lyrics = "[Verse 1]\nSome line\n\n[Banana]\nAnother line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Unrecognized metatag" in i for i in issues)
|
||||
|
||||
def test_valid_descriptor_metatags(self):
|
||||
lyrics = (
|
||||
"[Mood: haunting]\n\n"
|
||||
"[Verse 1]\n"
|
||||
"Walking through the fog\n"
|
||||
"Counting all the windows\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
# Descriptor metatags should not be flagged as unrecognized
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert not any("Mood" in i and "Unrecognized" in i for i in issues)
|
||||
|
||||
def test_empty_section(self):
|
||||
lyrics = "[Verse 1]\n\n[Chorus]\nSome chorus line\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Empty section" in i for i in issues)
|
||||
|
||||
def test_report_structure(self):
|
||||
lyrics = "[Verse 1]\nA simple test line here\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "metrics" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "validate" in result.stdout.lower()
|
||||
|
||||
def test_character_count_error(self):
|
||||
"""Lyrics exceeding 5000 chars (hard limit) should produce high severity finding."""
|
||||
# Build lyrics over 5000 characters (hard limit for v4.5+/v5/v5.5)
|
||||
line = "This is a long line of lyrics for testing character count limits yeah\n"
|
||||
lyrics = "[Verse 1]\n" + line * 80 # well over 5000 chars
|
||||
assert len(lyrics) > 5000
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "character count" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert any(f["severity"] == "high" for f in issues)
|
||||
|
||||
def test_character_count_warning(self):
|
||||
"""Lyrics between 3000 and 5000 chars should produce medium severity finding (quality degrades)."""
|
||||
# Build lyrics between 3000 and 5000 characters (quality budget exceeded)
|
||||
line = "This is a medium line of lyrics for testing\n"
|
||||
base = "[Verse 1]\n"
|
||||
# Each line is 45 chars. Need total between 3000 and 5000.
|
||||
count = 72 # 10 + 72*45 = 3250
|
||||
lyrics = base + line * count
|
||||
total = len(lyrics)
|
||||
assert 3000 < total < 5000, f"Got {total} chars"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "character count" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert any(f["severity"] == "medium" for f in issues)
|
||||
|
||||
def test_character_count_in_metrics(self):
|
||||
"""Report metrics should include character_count."""
|
||||
lyrics = "[Verse 1]\nHello world\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
assert "character_count" in report["metrics"]
|
||||
assert report["metrics"]["character_count"] == len(lyrics)
|
||||
|
||||
def test_punctuation_density_detection(self):
|
||||
"""Lines with heavy punctuation should trigger a rhythm finding."""
|
||||
lyrics = "[Verse 1]\nwell, - ; : ... yes\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
issues = [f for f in report["findings"] if "punctuation" in f["issue"].lower()]
|
||||
assert len(issues) >= 1
|
||||
assert issues[0]["severity"] == "low"
|
||||
assert issues[0]["category"] == "rhythm"
|
||||
|
||||
def test_clean_lyrics_normal_punctuation_passes(self):
|
||||
"""Clean lyrics with normal punctuation should pass without punctuation findings."""
|
||||
lyrics = (
|
||||
"[Verse 1]\n"
|
||||
"Walking through the morning light\n"
|
||||
"Counting shadows on the wall\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
"\n"
|
||||
"[Verse 2]\n"
|
||||
"Fingerprints on frosted glass\n"
|
||||
"Letters folded into cranes\n"
|
||||
"\n"
|
||||
"[Chorus]\n"
|
||||
"Come undone, come undone\n"
|
||||
"Let the weight fall where it may\n"
|
||||
)
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None
|
||||
punct_issues = [f for f in report["findings"] if "punctuation" in f["issue"].lower()]
|
||||
assert len(punct_issues) == 0
|
||||
|
||||
def test_new_section_tags_recognized(self):
|
||||
"""New section tags like Guitar Solo, Instrumental, etc. should not be flagged."""
|
||||
new_tags = [
|
||||
"Guitar Solo", "Piano Solo", "Sax Solo", "Saxophone Solo",
|
||||
"Drum Solo", "Bass Solo", "Solo", "Instrumental", "Interlude",
|
||||
"Build", "Build-Up", "Buildup", "Drop", "Hook", "Refrain",
|
||||
"Post-Chorus", "End", "Fade Out", "Fade In", "Break",
|
||||
]
|
||||
for tag in new_tags:
|
||||
lyrics = f"[Verse 1]\nSome line one\nSome line two\n\n[{tag}]\nContent here\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None, f"No report for [{tag}]"
|
||||
unrecognized = [f for f in report["findings"]
|
||||
if "Unrecognized" in f.get("issue", "") and tag in f.get("issue", "")]
|
||||
assert len(unrecognized) == 0, f"[{tag}] was flagged as unrecognized"
|
||||
|
||||
def test_vocal_cues_recognized(self):
|
||||
"""Vocal delivery cues like [Harmonized] should not be flagged as unrecognized."""
|
||||
cues = ["Harmonized", "Hummed", "Humming", "Whistled", "Whistling",
|
||||
"Crooning", "Scat", "Call and Response"]
|
||||
for cue in cues:
|
||||
lyrics = f"[Verse 1]\nSome line here\n[{cue}]\nMore content\n"
|
||||
report, code = run_script("--text", lyrics)
|
||||
assert report is not None, f"No report for [{cue}]"
|
||||
unrecognized = [f for f in report["findings"]
|
||||
if "Unrecognized" in f.get("issue", "") and cue in f.get("issue", "")]
|
||||
assert len(unrecognized) == 0, f"[{cue}] was flagged as unrecognized"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for validate-options.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = str(Path(__file__).parent.parent / "validate-options.py")
|
||||
|
||||
|
||||
def run_script(*args):
|
||||
"""Run the script and return parsed JSON output."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, *args],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout) if result.stdout else None, result.returncode
|
||||
|
||||
|
||||
class TestValidateOptions:
|
||||
def test_all_valid_codes(self):
|
||||
report, code = run_script("ST,CC,RA,CD")
|
||||
assert report is not None
|
||||
assert report["script"] == "validate-options"
|
||||
assert report["status"] == "pass"
|
||||
assert set(report["validated_codes"]) == {"ST", "CC", "RA", "CD"}
|
||||
assert report["removed_codes"] == []
|
||||
|
||||
def test_invalid_code(self):
|
||||
report, code = run_script("ST,ZZ,RA")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("ZZ" in i for i in issues)
|
||||
|
||||
def test_fr_wf_mutual_exclusion(self):
|
||||
report, code = run_script("FR,WF")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("mutually exclusive" in i.lower() for i in issues)
|
||||
|
||||
def test_fr_auto_removes_ce(self):
|
||||
report, code = run_script("FR,CE,RA")
|
||||
assert report is not None
|
||||
assert "CE" in report["removed_codes"]
|
||||
assert "CE" not in report["validated_codes"]
|
||||
assert "FR" in report["validated_codes"]
|
||||
assert "RA" in report["validated_codes"]
|
||||
|
||||
def test_ce_cc_info_note(self):
|
||||
report, code = run_script("CE,CC")
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("CC" in i and "redundant" in i.lower() for i in issues)
|
||||
# CC should still be in validated codes (info only, not removed)
|
||||
assert "CC" in report["validated_codes"]
|
||||
|
||||
def test_empty_codes(self):
|
||||
report, code = run_script("--codes", "")
|
||||
assert report is not None
|
||||
assert report["status"] == "error"
|
||||
assert any(f["severity"] == "critical" for f in report["findings"])
|
||||
|
||||
def test_codes_flag(self):
|
||||
report, code = run_script("--codes", "ST,RA")
|
||||
assert report is not None
|
||||
assert report["status"] == "pass"
|
||||
assert set(report["validated_codes"]) == {"ST", "RA"}
|
||||
|
||||
def test_duplicate_codes(self):
|
||||
report, code = run_script("ST,ST,RA")
|
||||
assert report is not None
|
||||
issues = [f["issue"] for f in report["findings"]]
|
||||
assert any("Duplicate" in i for i in issues)
|
||||
assert report["validated_codes"].count("ST") == 1
|
||||
|
||||
def test_help_flag(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, "--help"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "validate" in result.stdout.lower()
|
||||
|
||||
def test_report_structure(self):
|
||||
report, code = run_script("ST")
|
||||
assert report is not None
|
||||
assert "script" in report
|
||||
assert "version" in report
|
||||
assert "timestamp" in report
|
||||
assert "status" in report
|
||||
assert "validated_codes" in report
|
||||
assert "removed_codes" in report
|
||||
assert "findings" in report
|
||||
assert "summary" in report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
427
.agent/skills/suno-lyric-transformer/scripts/validate-lyrics.py
Normal file
427
.agent/skills/suno-lyric-transformer/scripts/validate-lyrics.py
Normal file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Validate transformed lyrics structure for Suno compatibility.
|
||||
|
||||
Checks metatag formatting, section structure, blank line separators,
|
||||
style cue contamination, and reasonable song length.
|
||||
|
||||
Usage:
|
||||
python validate-lyrics.py <lyrics-file-or-text> [options]
|
||||
|
||||
# Validate lyrics from a file
|
||||
python validate-lyrics.py lyrics.txt
|
||||
|
||||
# Validate lyrics from stdin
|
||||
echo "[Verse 1]\\nHello world" | python validate-lyrics.py --stdin
|
||||
|
||||
# Validate with text argument
|
||||
python validate-lyrics.py --text "[Verse 1]\\nHello world"
|
||||
|
||||
# Output to file
|
||||
python validate-lyrics.py lyrics.txt -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import SUNO_LYRICS_HARD_LIMIT, SUNO_LYRICS_QUALITY_BUDGET
|
||||
|
||||
SCRIPT_NAME = "validate-lyrics"
|
||||
VERSION = "1.1.0"
|
||||
|
||||
# Valid section metatags (case-insensitive matching)
|
||||
VALID_SECTIONS = {
|
||||
"intro", "verse", "verse 1", "verse 2", "verse 3", "verse 4",
|
||||
"pre-chorus", "chorus", "bridge", "breakdown", "build-up", "buildup",
|
||||
"final chorus", "outro", "hook", "refrain", "interlude",
|
||||
"post-chorus", "solo",
|
||||
# Instrumental / solo variants
|
||||
"guitar solo", "piano solo", "sax solo", "saxophone solo",
|
||||
"drum solo", "bass solo", "instrumental",
|
||||
# Structural tags
|
||||
"build", "drop", "break", "end",
|
||||
"fade out", "fade in",
|
||||
}
|
||||
|
||||
# Valid vocal delivery cues (inline metatags, not section tags)
|
||||
VALID_VOCAL_CUES = {
|
||||
"harmonized", "hummed", "humming", "whistled", "whistling",
|
||||
"crooning", "scat", "call and response",
|
||||
}
|
||||
|
||||
# Valid descriptor metatag prefixes
|
||||
VALID_DESCRIPTORS = {"mood", "energy", "vocal style", "instrument", "tempo", "key"}
|
||||
|
||||
# HIGH-confidence standalone bare-bracket tags from metatag-reference.md
|
||||
# Kept in sync with the "Standalone Mood/Energy Tags" and "Timing & Rhythm Tags" sections.
|
||||
VALID_STANDALONE_MOODS = {
|
||||
"uplifting", "haunting", "dark", "nostalgic", "somber", "romantic",
|
||||
"dreamy", "peaceful", "anxious", "euphoric", "mysterious", "playful",
|
||||
"epic", "intimate", "bittersweet", "triumphant",
|
||||
}
|
||||
|
||||
VALID_STANDALONE_ENERGY = {
|
||||
"high energy", "medium energy", "low energy", "chill", "driving",
|
||||
"explosive", "building", "relaxed", "frantic", "steady",
|
||||
}
|
||||
|
||||
VALID_TIMING_RHYTHM = {
|
||||
"half-time", "swung feel", "shuffle", "triplet feel", "syncopated",
|
||||
"straight", "four on the floor", "polyrhythmic", "breakbeat",
|
||||
}
|
||||
|
||||
# Style cues that should NOT be in lyrics
|
||||
STYLE_CONTAMINATION_PATTERNS = [
|
||||
r'\b(?:BPM|bpm)\b',
|
||||
r'\b(?:stereo|mono)\s+(?:field|mix)\b',
|
||||
r'\b(?:radio[- ]ready|lo[- ]fi|hi[- ]fi)\b',
|
||||
r'\b(?:punchy|warm|crisp)\s+(?:drums|bass|mix|production)\b',
|
||||
]
|
||||
|
||||
# Reasonable song length bounds (in non-empty, non-tag lines)
|
||||
MIN_LYRIC_LINES = 8
|
||||
MAX_LYRIC_LINES = 80
|
||||
RECOMMENDED_MAX_SECTIONS = 12
|
||||
|
||||
|
||||
|
||||
def parse_lyrics(text: str) -> dict:
|
||||
"""Parse lyrics into structured sections with line data."""
|
||||
lines = text.split('\n')
|
||||
sections = []
|
||||
current_section = None
|
||||
all_tags = []
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
|
||||
# Check if this is a metatag
|
||||
tag_match = re.match(r'^\[([^\]]+)\]$', stripped)
|
||||
if tag_match:
|
||||
tag_content = tag_match.group(1).strip()
|
||||
all_tags.append({"text": tag_content, "line": i})
|
||||
|
||||
# Check if it's a descriptor (has a colon)
|
||||
if ':' in tag_content:
|
||||
prefix = tag_content.split(':')[0].strip().lower()
|
||||
if prefix in VALID_DESCRIPTORS:
|
||||
if current_section is None:
|
||||
# Global descriptor — fine
|
||||
pass
|
||||
# Descriptor attached to current/next section — fine
|
||||
continue
|
||||
|
||||
# Check if it's a section tag
|
||||
tag_lower = tag_content.lower()
|
||||
# Strip numbers for matching: "Verse 1" -> "verse 1", but also match base "verse"
|
||||
is_section = (tag_lower in VALID_SECTIONS or
|
||||
tag_lower in VALID_VOCAL_CUES or
|
||||
re.match(r'^(verse|chorus|bridge|breakdown|build-up|buildup|pre-chorus|post-chorus|hook|refrain|interlude|solo|instrumental|break|drop|build|end|fade\s*(?:out|in))\s*\d*$', tag_lower))
|
||||
|
||||
if is_section:
|
||||
current_section = {
|
||||
"tag": tag_content,
|
||||
"line": i,
|
||||
"lyric_lines": [],
|
||||
"lyric_line_numbers": []
|
||||
}
|
||||
sections.append(current_section)
|
||||
continue
|
||||
|
||||
# Non-tag, non-empty line
|
||||
if stripped:
|
||||
if current_section:
|
||||
current_section["lyric_lines"].append(stripped)
|
||||
current_section["lyric_line_numbers"].append(i)
|
||||
|
||||
return {
|
||||
"sections": sections,
|
||||
"all_tags": all_tags,
|
||||
"total_lines": len(lines),
|
||||
"raw_text": text
|
||||
}
|
||||
|
||||
|
||||
def validate_lyrics(text: str) -> list[dict]:
|
||||
"""Validate lyrics text and return findings."""
|
||||
findings = []
|
||||
lines = text.split('\n')
|
||||
|
||||
if not text.strip():
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"issue": "Lyrics text is empty.",
|
||||
"fix": "Provide lyrics with at least one section and content."
|
||||
})
|
||||
return findings
|
||||
|
||||
parsed = parse_lyrics(text)
|
||||
sections = parsed["sections"]
|
||||
|
||||
# Check for at least one section tag
|
||||
if not sections:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"issue": "No section metatags found. Suno uses tags like [Verse], [Chorus] to structure songs.",
|
||||
"fix": "Add section tags to define song structure."
|
||||
})
|
||||
|
||||
# Check for blank lines between sections
|
||||
for section in sections:
|
||||
line_num = section["line"]
|
||||
if line_num > 1:
|
||||
prev_line = lines[line_num - 2].strip() if line_num - 1 < len(lines) else ""
|
||||
if prev_line and not prev_line.startswith('['):
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"location": {"line": line_num},
|
||||
"issue": f"No blank line before section tag [{section['tag']}] at line {line_num}.",
|
||||
"fix": "Add a blank line before each section tag for cleaner Suno parsing."
|
||||
})
|
||||
|
||||
# Check for style cues in lyrics
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
if not stripped or re.match(r'^\[.*\]$', stripped):
|
||||
continue
|
||||
for pattern in STYLE_CONTAMINATION_PATTERNS:
|
||||
if re.search(pattern, stripped, re.IGNORECASE):
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"line": i},
|
||||
"issue": f"Possible style cue in lyrics at line {i}: '{stripped[:60]}...'",
|
||||
"fix": "Style descriptions belong in the style prompt, not in lyrics."
|
||||
})
|
||||
break
|
||||
|
||||
# Check for asterisks
|
||||
for i, line in enumerate(lines, 1):
|
||||
if '*' in line:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"location": {"line": i},
|
||||
"issue": f"Asterisk found in lyrics at line {i}. Suno doesn't use markdown.",
|
||||
"fix": "Remove asterisks from lyrics."
|
||||
})
|
||||
|
||||
# Count actual lyric lines (non-empty, non-tag)
|
||||
lyric_lines = [line.strip() for line in lines if line.strip() and not re.match(r'^\[.*\]$', line.strip())]
|
||||
lyric_count = len(lyric_lines)
|
||||
|
||||
if lyric_count < MIN_LYRIC_LINES:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"issue": f"Very short lyrics ({lyric_count} lines). May produce a very short song.",
|
||||
"fix": "Consider adding more content or sections for a full-length song."
|
||||
})
|
||||
|
||||
# Character count check (Suno counts everything including metatags)
|
||||
char_count = len(text)
|
||||
if char_count > SUNO_LYRICS_HARD_LIMIT:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"issue": f"Total character count ({char_count}) exceeds Suno's {SUNO_LYRICS_HARD_LIMIT}-character limit. Suno will truncate your lyrics.",
|
||||
"fix": "Trim lyrics to stay under 5,000 characters (hard limit). For best quality, aim for ~3,000 characters."
|
||||
})
|
||||
elif char_count > SUNO_LYRICS_QUALITY_BUDGET:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"issue": f"Total character count ({char_count}) is approaching Suno's {SUNO_LYRICS_HARD_LIMIT}-character limit.",
|
||||
"fix": "Consider trimming — quality degrades above ~3,000 characters. Hard limit is 5,000."
|
||||
})
|
||||
|
||||
if lyric_count > MAX_LYRIC_LINES:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "structure",
|
||||
"issue": f"Very long lyrics ({lyric_count} lines). Suno may not render all content.",
|
||||
"fix": "Consider trimming to a more standard song length (20-50 lyric lines)."
|
||||
})
|
||||
|
||||
# Check section count
|
||||
if len(sections) > RECOMMENDED_MAX_SECTIONS:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"issue": f"High section count ({len(sections)}). Songs typically have 6-10 sections.",
|
||||
"fix": "Consider consolidating sections for a cleaner structure."
|
||||
})
|
||||
|
||||
# Check for invalid metatags
|
||||
for tag_info in parsed["all_tags"]:
|
||||
tag_text = tag_info["text"]
|
||||
tag_lower = tag_text.lower()
|
||||
# Is it a valid section?
|
||||
is_section = (tag_lower in VALID_SECTIONS or
|
||||
re.match(r'^(verse|chorus|bridge|breakdown|build-up|buildup|pre-chorus|post-chorus|hook|refrain|interlude|solo|instrumental|break|drop|build|end|fade\s*(?:out|in))\s*\d*$', tag_lower))
|
||||
# Is it a valid vocal delivery cue?
|
||||
is_vocal_cue = tag_lower in VALID_VOCAL_CUES
|
||||
# Is it a valid descriptor?
|
||||
is_descriptor = ':' in tag_text and tag_text.split(':')[0].strip().lower() in VALID_DESCRIPTORS
|
||||
# Is it a HIGH-confidence standalone mood/energy/rhythm tag from metatag-reference.md?
|
||||
is_standalone = (tag_lower in VALID_STANDALONE_MOODS or
|
||||
tag_lower in VALID_STANDALONE_ENERGY or
|
||||
tag_lower in VALID_TIMING_RHYTHM)
|
||||
|
||||
if not is_section and not is_vocal_cue and not is_descriptor and not is_standalone:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"line": tag_info["line"]},
|
||||
"issue": f"Unrecognized metatag [{tag_text}] at line {tag_info['line']}. May not be interpreted by Suno.",
|
||||
"fix": "Use standard section tags or descriptor tags (Mood/Energy/Vocal Style/Instrument)."
|
||||
})
|
||||
|
||||
# Punctuation density check
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
if not stripped or re.match(r'^\[.*\]$', stripped):
|
||||
continue
|
||||
words = stripped.split()
|
||||
word_count = len(words)
|
||||
if word_count == 0:
|
||||
continue
|
||||
# Count commas, dashes, semicolons, colons, ellipses
|
||||
punct_count = (
|
||||
stripped.count(',') + stripped.count('-') + stripped.count(';')
|
||||
+ stripped.count(':') + stripped.count('...')
|
||||
)
|
||||
density = punct_count / word_count
|
||||
if density > 0.5:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "rhythm",
|
||||
"location": {"line": i},
|
||||
"issue": f"Heavy punctuation density ({density:.2f}) at line {i}: '{stripped[:60]}'. Heavy punctuation can confuse Suno's cadence.",
|
||||
"fix": "Simplify punctuation to let Suno interpret natural phrasing."
|
||||
})
|
||||
|
||||
# Check for empty sections
|
||||
for section in sections:
|
||||
if not section["lyric_lines"]:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"location": {"line": section["line"]},
|
||||
"issue": f"Empty section [{section['tag']}] at line {section['line']}.",
|
||||
"fix": "Add lyrics to this section or remove the tag if it's meant to be instrumental."
|
||||
})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def build_report(findings: list, text: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
for f in findings:
|
||||
if "location" not in f:
|
||||
f["location"] = {"file": "lyrics"}
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["critical"] > 0:
|
||||
status = "fail"
|
||||
elif severity_counts["high"] > 0:
|
||||
status = "warning"
|
||||
|
||||
parsed = parse_lyrics(text)
|
||||
lyric_lines = [line.strip() for line in text.split('\n')
|
||||
if line.strip() and not re.match(r'^\[.*\]$', line.strip())]
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"metrics": {
|
||||
"total_lines": parsed["total_lines"],
|
||||
"lyric_lines": len(lyric_lines),
|
||||
"character_count": len(text),
|
||||
"section_count": len(parsed["sections"]),
|
||||
"sections": [s["tag"] for s in parsed["sections"]]
|
||||
},
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate transformed lyrics structure for Suno compatibility.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s lyrics.txt
|
||||
%(prog)s --text "[Verse 1]\\nHello world"
|
||||
%(prog)s --stdin < lyrics.txt
|
||||
%(prog)s lyrics.txt -o results.json --verbose
|
||||
|
||||
Exit codes: 0=pass, 1=fail/warning, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Path to lyrics text file")
|
||||
parser.add_argument("--text", help="Lyrics text to validate directly")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read lyrics from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
text = ""
|
||||
if args.text is not None:
|
||||
text = args.text.replace('\\n', '\n')
|
||||
elif args.stdin:
|
||||
text = sys.stdin.read()
|
||||
elif args.file:
|
||||
file_path = Path(args.file)
|
||||
if not file_path.exists():
|
||||
print(f"Error: File not found: {args.file}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
text = file_path.read_text()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Validating lyrics ({len(text)} chars, {len(text.splitlines())} lines)...", file=sys.stderr)
|
||||
|
||||
findings = validate_lyrics(text)
|
||||
report = build_report(findings, text, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
224
.agent/skills/suno-lyric-transformer/scripts/validate-options.py
Normal file
224
.agent/skills/suno-lyric-transformer/scripts/validate-options.py
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Validate transformation option selections against mutual exclusion rules.
|
||||
|
||||
Checks that selected transformation option codes are valid and consistent,
|
||||
enforcing mutual exclusion and dependency rules between options.
|
||||
|
||||
Usage:
|
||||
python validate-options.py <option-codes> [options]
|
||||
|
||||
# Validate option codes from positional argument
|
||||
python validate-options.py "ST,CC,RA,CD"
|
||||
|
||||
# Validate with --codes flag
|
||||
python validate-options.py --codes "ST,CC,RA,CD"
|
||||
|
||||
# Output to file
|
||||
python validate-options.py "ST,CC,RA" -o results.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_NAME = "validate-options"
|
||||
VERSION = "1.0.0"
|
||||
|
||||
VALID_CODES = {"ST", "CE", "CC", "RA", "FR", "CD", "WF"}
|
||||
|
||||
CODE_DESCRIPTIONS = {
|
||||
"ST": "Structural Transformation",
|
||||
"CE": "Cliche Elimination",
|
||||
"CC": "Consistency Check",
|
||||
"RA": "Rhyme Analysis",
|
||||
"FR": "Full Rewrite",
|
||||
"CD": "Cliche Detection",
|
||||
"WF": "Word Flow",
|
||||
}
|
||||
|
||||
|
||||
def validate_options(codes_str: str) -> dict:
|
||||
"""Validate option codes and return results with findings."""
|
||||
raw_codes = [c.strip().upper() for c in codes_str.split(",") if c.strip()]
|
||||
findings = []
|
||||
removed_codes = []
|
||||
validated_codes = []
|
||||
|
||||
if not raw_codes:
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "validation",
|
||||
"issue": "No option codes provided.",
|
||||
"fix": "Provide at least one valid option code: " + ", ".join(sorted(VALID_CODES))
|
||||
})
|
||||
return {
|
||||
"validated_codes": [],
|
||||
"removed_codes": [],
|
||||
"findings": findings
|
||||
}
|
||||
|
||||
# Check for invalid codes
|
||||
invalid = [c for c in raw_codes if c not in VALID_CODES]
|
||||
valid_input = [c for c in raw_codes if c in VALID_CODES]
|
||||
|
||||
for code in invalid:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "validation",
|
||||
"issue": f"Invalid option code: '{code}'.",
|
||||
"fix": f"Valid codes are: {', '.join(sorted(VALID_CODES))}"
|
||||
})
|
||||
|
||||
# Check for duplicates
|
||||
seen = set()
|
||||
deduped = []
|
||||
for code in valid_input:
|
||||
if code in seen:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "validation",
|
||||
"issue": f"Duplicate option code: '{code}'.",
|
||||
"fix": "Each code should appear only once."
|
||||
})
|
||||
else:
|
||||
seen.add(code)
|
||||
deduped.append(code)
|
||||
|
||||
working = list(deduped)
|
||||
|
||||
# FR and WF are mutually exclusive
|
||||
if "FR" in working and "WF" in working:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "exclusion",
|
||||
"issue": "FR (Full Rewrite) and WF (Word Flow) are mutually exclusive.",
|
||||
"fix": "Choose either FR or WF, not both."
|
||||
})
|
||||
|
||||
# CE is skipped if FR is selected (warn, auto-remove CE)
|
||||
if "FR" in working and "CE" in working:
|
||||
working.remove("CE")
|
||||
removed_codes.append("CE")
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "dependency",
|
||||
"issue": "CE (Cliche Elimination) auto-removed: redundant when FR (Full Rewrite) is selected.",
|
||||
"fix": "FR already encompasses cliche elimination."
|
||||
})
|
||||
|
||||
# CC is skipped if CE is selected (info, can be overridden)
|
||||
if "CE" in working and "CC" in working:
|
||||
findings.append({
|
||||
"severity": "info",
|
||||
"category": "dependency",
|
||||
"issue": "CC (Consistency Check) may be redundant when CE (Cliche Elimination) is selected.",
|
||||
"fix": "CE may alter consistency; CC can still be kept if desired."
|
||||
})
|
||||
|
||||
validated_codes = working
|
||||
|
||||
return {
|
||||
"validated_codes": validated_codes,
|
||||
"removed_codes": removed_codes,
|
||||
"findings": findings
|
||||
}
|
||||
|
||||
|
||||
def build_report(result: dict, codes_str: str, skill_path: str = "") -> dict:
|
||||
"""Build the standard output report."""
|
||||
findings = result["findings"]
|
||||
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["critical"] > 0 or severity_counts["high"] > 0:
|
||||
status = "error"
|
||||
elif severity_counts["medium"] > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": SCRIPT_NAME,
|
||||
"version": VERSION,
|
||||
"skill_path": skill_path,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"validated_codes": result["validated_codes"],
|
||||
"removed_codes": result["removed_codes"],
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate transformation option selections against mutual exclusion rules.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s "ST,CC,RA,CD"
|
||||
%(prog)s --codes "ST,CC,RA,CD"
|
||||
%(prog)s "FR,CE" -o results.json --verbose
|
||||
|
||||
Valid codes: ST, CE, CC, RA, FR, CD, WF
|
||||
Rules:
|
||||
- FR and WF are mutually exclusive
|
||||
- CE is auto-removed when FR is selected
|
||||
- CC info note when CE is selected
|
||||
|
||||
Exit codes: 0=pass, 1=issues, 2=error
|
||||
"""
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Comma-separated option codes (positional)")
|
||||
parser.add_argument("--codes", help="Comma-separated option codes")
|
||||
parser.add_argument("--text", help="Alias for --codes (for consistency)")
|
||||
parser.add_argument("--stdin", action="store_true", help="Read codes from stdin")
|
||||
parser.add_argument("-o", "--output", help="Output file path (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument("--skill-path", default="", help="Skill path for report context")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
codes_str = ""
|
||||
if args.codes is not None:
|
||||
codes_str = args.codes
|
||||
elif args.text is not None:
|
||||
codes_str = args.text
|
||||
elif args.stdin:
|
||||
codes_str = sys.stdin.read().strip()
|
||||
elif args.file:
|
||||
codes_str = args.file
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(2)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Validating option codes: {codes_str}", file=sys.stderr)
|
||||
|
||||
result = validate_options(codes_str)
|
||||
report = build_report(result, codes_str, args.skill_path)
|
||||
|
||||
output_json = json.dumps(report, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output_json)
|
||||
if args.verbose:
|
||||
print(f"Report written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output_json)
|
||||
|
||||
sys.exit(0 if report["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user