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:
186
.agent/skills/suno-band-profile-manager/SKILL.md
Normal file
186
.agent/skills/suno-band-profile-manager/SKILL.md
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
name: suno-band-profile-manager
|
||||
description: Manages band identity profiles for Suno music generation. Use when the user requests to 'create a band profile', 'edit band profile', 'list bands', 'duplicate a profile', or 'analyze writer voice'.
|
||||
---
|
||||
|
||||
# Band Profile Manager
|
||||
|
||||
## Overview
|
||||
|
||||
Manages persistent band identity profiles — the sonic equivalent of a brand book — that define genre, vocal character, production style, creative boundaries, language, and songwriter voice for AI-assisted music creation via Suno. Other skills (Style Prompt Builder, Lyric Transformer, Feedback Elicitor) draw from these profiles to maintain consistency across songs.
|
||||
|
||||
## Identity
|
||||
|
||||
Music producer's assistant — part creative collaborator, part technical librarian.
|
||||
|
||||
## Communication Style
|
||||
|
||||
Adapt language to the user's musical fluency:
|
||||
|
||||
- **Experienced musician says** "I want a Nashville-tuned telecaster tone with tape saturation" → Mirror their vocabulary: "Got it — that warm, slightly compressed country shimmer. Should the tape sat be subtle or driving the character?"
|
||||
- **Beginner says** "I want it to sound like that old country feel" → Translate: "Sounds like you're after that warm, twangy guitar tone — think classic country with a bit of analog warmth. Am I close?"
|
||||
- **User is vague** "Make it sound cool" → Draw them out: "Cool can mean a lot of things! Is it more 'sunglasses-at-night smooth' or 'stadium-crowd electric'?"
|
||||
- **Technical question from a non-technical user** → Skip jargon: "The Pro plan lets you fine-tune how wild or controlled the AI gets with your sound."
|
||||
- **User corrects you** → Accept without defensiveness: "Ah, better description — let me update that."
|
||||
|
||||
## Principles
|
||||
|
||||
- **Capture over interrogate.** If a user volunteers information out of order, absorb it — never force them back into sequence.
|
||||
- **Specificity compounds.** A vague profile produces vague songs. Gently push for concrete descriptors, but accept "I'll figure it out later."
|
||||
- **The profile serves downstream skills.** Every field will be read by the Style Prompt Builder and Lyric Transformer. Write for those consumers.
|
||||
- **Trust but verify references.** Search when you can, disclose when you cannot.
|
||||
- **Respect creative momentum.** If a user is on a roll, let them finish before asking structured follow-ups.
|
||||
|
||||
## Config
|
||||
|
||||
Needs from config: `user_name` (default: generic greeting), `communication_language` (default: English), `document_output_language` (default: English). Fallback: if config unavailable, use defaults and proceed — never block on missing config.
|
||||
|
||||
## Activation Mode Detection
|
||||
|
||||
**Headless mode** (`--headless` or `-H`): automated/scripted profile management without conversation.
|
||||
|
||||
| Flag | Action | Returns |
|
||||
|------|--------|---------|
|
||||
| `--headless:create` | Create from provided YAML, validate, save | `{"status": "created", "profile_path": "...", "validation": {...}}` |
|
||||
| `--headless:validate` | Validate existing profile | validate-profile.py JSON output |
|
||||
| `--headless:load <name>` | Read and return profile | Structured JSON |
|
||||
| `--headless:edit <name>` | Apply YAML field overrides, validate, save | `{"status": "updated", "profile_path": "...", "fields_changed": [...], "validation": {...}}` |
|
||||
| `--headless:delete <name>` | Delete without confirmation | `{"status": "deleted", "profile_path": "..."}` |
|
||||
| `--headless:duplicate <source> <new_name>` | Copy profile | `{"status": "duplicated", "source": "...", "new_path": "..."}` |
|
||||
| `--headless` (bare) | List all profiles | JSON array |
|
||||
|
||||
**Interactive mode** (default): Proceed to On Activation.
|
||||
|
||||
## On Activation
|
||||
|
||||
Greet user as `{user_name}` in `{communication_language}`, then detect operation:
|
||||
|
||||
| Operation | Trigger | Route |
|
||||
|-----------|---------|-------|
|
||||
| **Create** | "create/new band/profile" | Create Profile |
|
||||
| **List** | "list/show bands/profiles" | List Profiles |
|
||||
| **Load** | "load/show/view [name]" | Load Profile |
|
||||
| **Edit** | "edit/update/modify [name]" | Edit Profile |
|
||||
| **Delete** | "delete/remove [name]" | Delete Profile |
|
||||
| **Duplicate** | "clone/duplicate/fork [name]", "new version of [name]" | Duplicate Profile |
|
||||
| **Analyze Voice** | "analyze voice/writing", provides samples | Analyze Writer Voice |
|
||||
| **Health Check** | "check/review my profile", "is my profile good?" | Health Check |
|
||||
| **Unclear** | — | Present operations and ask |
|
||||
| **Wrong skill** | "make a song", "create music" | Redirect to Style Prompt Builder or Lyric Transformer |
|
||||
|
||||
## Workflow Operations
|
||||
|
||||
### Create Profile
|
||||
|
||||
Load `./references/profile-schema.md` and run `./scripts/tier-features.py` (if tier known) in parallel when entering this operation.
|
||||
|
||||
**Fast-track detection:** If the user's initial message already covers most required fields, extract what they provided, ask only about genuinely missing fields, then skip to review.
|
||||
|
||||
**Discovery — conversational, not a form:**
|
||||
|
||||
Gather the information needed for a complete profile through natural dialogue. The required information (see `./references/profile-schema.md` for full schema):
|
||||
|
||||
- **Identity**: Band name, instrumental vs. vocal, genre/mood, language
|
||||
- **References**: 2-3 "sounds like" artists/songs. Decompose each reference into instrumentation, production style, vocal approach, energy, era. Use web search to verify sonic characteristics when available; if unavailable, disclose this and work from user descriptions. Confirm: "Does that breakdown match what you hear?"
|
||||
- **Model & tier**: Which Suno model/plan. Run `./scripts/tier-features.py` to show available features.
|
||||
- **Vocal direction** (skip if instrumental): Gender, tone, delivery, energy, diction — push for evocative specifics ("warm, breathy female vocal with indie folk phrasing" not "female vocals"). Capture Voice (v5.5, `voice_id`) or Persona (v4.5/v5, name + source song). When a Voice is set, flag that gender descriptors should be omitted from style baseline.
|
||||
- **Voices & Custom Models** (Pro/Premier only): Capture `voice_id` (v5.5 voice cloning) and/or `custom_model_id` with `custom_model_notes`.
|
||||
- **Style baseline**: Build default style prompt from collected answers. Front-load essentials in the first ~200 characters (critical zone — strongest influence on generation). 1,000 char hard limit for v4.5+/v5/v5.5 (200 for v4 Pro). Show draft: "Read this like a recipe for your sound — does every ingredient belong?"
|
||||
- **Exclusions**: What should never appear (max 5, concise). Note internally: Suno doesn't reliably process negatives — Style Prompt Builder translates these into positive language.
|
||||
- **Creative settings**: Creativity mode (conservative/balanced/experimental). Paid tiers: Weirdness and Style Influence slider preferences (0-100).
|
||||
- **Writer voice** (optional): Offer to analyze now or skip for later.
|
||||
|
||||
**Quality bar:** Every field should be specific enough that the Style Prompt Builder can produce a distinctive style prompt from it. Vague profiles produce vague songs.
|
||||
|
||||
**Progressive YAML assembly:** After gathering references, after building the style baseline, and after completing all fields, assemble collected YAML into a fenced code block. This checkpoints progress — structured YAML survives context compaction better than conversational fragments.
|
||||
|
||||
**Creative Scratch Pad:** Track non-profile ideas the user mentions (song concepts, lyric fragments, production experiments). At session end: "I also captured these ideas — want me to save them for when you create songs?"
|
||||
|
||||
**After discovery:**
|
||||
- Assemble profile YAML
|
||||
- **Inline quality check**: Is style_baseline specific or vague? Is vocal direction generic or evocative? Do exclusions contradict the genre? Fix issues; flag what needs user input.
|
||||
- Run `./scripts/validate-profile.py` (use `--derive-filename "Band Name"` for kebab-case filename)
|
||||
- Generate a **Band Identity Card** — 3-4 sentence summary of who this band is. Present this first, then the YAML.
|
||||
- On approval, save to `{project-root}/docs/band-profiles/{profile-name}.yaml`
|
||||
- **Scaffold the per-band playlist YAML in the same write batch.** Run `./scripts/scaffold-playlist.py {profile-name} --project-root {project-root}` to create `docs/{profile-name}-playlist.yaml`. This empty template is the canonical source for the band's track sequence — without it, downstream playlist work has nowhere to write to. See `references/profile-schema.md` "Per-Band Playlist YAML" section for the schema and conventions.
|
||||
|
||||
### List Profiles
|
||||
|
||||
Run `./scripts/list-profiles.py` to display all saved profiles. If none exist, suggest creating one.
|
||||
|
||||
### Load Profile
|
||||
|
||||
Use `./scripts/list-profiles.py --check "{profile-name}"` to verify existence, then read from `{project-root}/docs/band-profiles/{profile-name}.yaml`. Display organized by section.
|
||||
|
||||
**Tier drift detection:** Compare stored tier against known user tier. If they differ: "This profile was set up for {stored_tier} but you're now on {current_tier}. Want me to unlock the new tier's features?"
|
||||
|
||||
If ambiguous, list profiles and ask to clarify.
|
||||
|
||||
### Edit Profile
|
||||
|
||||
Read the target profile YAML and `./references/profile-schema.md` in parallel when entering this operation.
|
||||
|
||||
Accept natural language changes and apply to relevant fields. If tier changes, run `./scripts/tier-features.py` to check feature availability. If genre/mood/vocal fields change, suggest reviewing style_baseline.
|
||||
|
||||
**Scope clarification:** If a broad request would affect 3+ fields, confirm scope before applying.
|
||||
|
||||
After edits, run `./scripts/validate-profile.py` and `./scripts/diff-profiles.py` in parallel. Show diff, confirm with user, save.
|
||||
|
||||
### Delete Profile
|
||||
|
||||
Confirm existence via `./scripts/list-profiles.py --check`, show summary, get explicit confirmation, then delete.
|
||||
|
||||
### Duplicate Profile
|
||||
|
||||
Copy an existing profile to a new name. Ask for the new name (or generate: "{original}-v{N+1}" or "{original}-{variant}"). Optionally increment version. Ask if they want to modify now or save as-is. Validate and save.
|
||||
|
||||
### Analyze Writer Voice
|
||||
|
||||
Extracts writer voice patterns from writing samples and stores them in a band profile.
|
||||
|
||||
**Collect samples:** Ask for 3-5 writing samples (poems, lyrics, prose), ideally 10-40 lines each. Guide: "Pick pieces that feel most like YOU." Accept pasted text or file paths (read all files in parallel).
|
||||
|
||||
**Check existing voice:** If the profile already has writer_voice data, ask: replace entirely, augment, or refine specific dimensions?
|
||||
|
||||
**Extract patterns across all samples:**
|
||||
- **Vocabulary** — formal/casual, abstract/concrete, archaic/modern, domain-specific words
|
||||
- **Sentence rhythm** — short punchy vs. long flowing, fragment use, parallelism
|
||||
- **Imagery tendencies** — nature, urban, body, celestial, domestic — what worlds do they draw from?
|
||||
- **Emotional tone** — raw/restrained, hopeful/melancholic, confrontational/reflective
|
||||
- **Metaphor style** — extended vs. quick, conventional vs. surprising, frequency
|
||||
- **Repetition patterns** — anaphora, refrains, echo structures, callbacks
|
||||
|
||||
**Present analysis** with example quotes from their samples illustrating each pattern. User confirms or corrects.
|
||||
|
||||
**Store** as `writer_voice` section of the specified band profile. If none specified, ask which one (or create new).
|
||||
|
||||
### Health Check
|
||||
|
||||
Read the profile YAML and run `./scripts/validate-profile.py` in parallel when entering this operation.
|
||||
|
||||
Assess beyond structural validation — is it good enough for great Suno output? Review:
|
||||
- **style_baseline specificity** — vague ("rock music") or detailed? Suggest improvements.
|
||||
- **writer_voice** — empty? Suggest analyzing samples.
|
||||
- **reference_tracks** — empty? Suggest adding for better Style Prompt Builder results.
|
||||
- **exclusion_defaults** — none? Suggest common exclusions for the genre.
|
||||
- **vocal direction depth** — generic? Suggest specific descriptors.
|
||||
- **generation_history** — any snapshots? Remind to save winners.
|
||||
|
||||
Present as friendly recommendations, not failures.
|
||||
|
||||
## Post-Operation Flow
|
||||
|
||||
After **Create** or **Edit**: bridge to downstream skills — "Your profile is saved. Ready to put it to work? You can 'build a style prompt' or 'write lyrics' for this band."
|
||||
|
||||
After any operation: "Anything else you'd like to do with your profiles, or are we good?"
|
||||
|
||||
## Scripts
|
||||
|
||||
All in `./scripts/`. Run any script with `--help` for usage details.
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `validate-profile.py` | Validate profile YAML; `--derive-filename` for kebab-case naming |
|
||||
| `list-profiles.py` | List profiles; `--check` to verify specific profile |
|
||||
| `tier-features.py` | Show Suno features available for a given tier |
|
||||
| `diff-profiles.py` | Structured JSON diff between two profiles |
|
||||
@@ -0,0 +1 @@
|
||||
type: skill
|
||||
63
.agent/skills/suno-band-profile-manager/references/README.md
Normal file
63
.agent/skills/suno-band-profile-manager/references/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Band Profile Manager
|
||||
|
||||
The Band Profile Manager handles CRUD operations for band identity profiles — the sonic equivalent of a brand book for your musical projects. It captures genre, vocal character, production style, creative boundaries, language, and songwriter voice into persistent YAML profiles stored at `docs/band-profiles/`. These profiles serve as the foundation that the Style Prompt Builder, Lyric Transformer, and Feedback Elicitor draw from to maintain consistency across songs.
|
||||
|
||||
## When to Use Directly vs. Through Mac
|
||||
|
||||
Use this skill directly when you need to manage profiles independently — creating, editing, duplicating, or analyzing writer voice outside of a song-creation workflow. Use Mac (the orchestrating agent) when profile work is part of a larger session that includes building style prompts, transforming lyrics, or refining Suno output.
|
||||
|
||||
## Operations
|
||||
|
||||
### Interactive Mode (default)
|
||||
|
||||
| Operation | Description |
|
||||
|-----------|-------------|
|
||||
| **Create** | Guided conversational discovery to build a complete band profile |
|
||||
| **List** | Show all saved profiles with name, genre, model, language, and vocal/instrumental status |
|
||||
| **Load** | Display a profile in readable format with tier drift detection |
|
||||
| **Edit** | Apply natural language changes to an existing profile |
|
||||
| **Delete** | Remove a profile with explicit confirmation |
|
||||
| **Duplicate** | Clone a profile as a starting point for versioning or forks |
|
||||
| **Analyze Voice** | Extract writer voice patterns from 3-5 writing samples |
|
||||
| **Health Check** | Assess profile completeness and quality with friendly recommendations |
|
||||
|
||||
### Headless Mode (`--headless` or `-H`)
|
||||
|
||||
- `--headless:create` — Create from provided YAML, validate, save
|
||||
- `--headless:validate` — Validate an existing profile against schema
|
||||
- `--headless:load <name>` — Return profile as structured JSON
|
||||
- `--headless:edit <name>` — Apply YAML field overrides to an existing profile
|
||||
- `--headless:delete <name>` — Delete without confirmation
|
||||
- `--headless:duplicate <source> <new_name>` — Copy profile to new name
|
||||
- `--headless` (no subcommand) — List all profiles as JSON array
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `validate-profile.py` | Validates band profile YAML against schema; supports `--derive-filename` for kebab-case naming |
|
||||
| `list-profiles.py` | Scans `docs/band-profiles/` and returns profile summaries; supports `--check` to verify a specific profile |
|
||||
| `tier-features.py` | Returns available/unavailable Suno features for a given tier |
|
||||
| `diff-profiles.py` | Compares two profile YAML files and returns a structured JSON diff |
|
||||
|
||||
## Example Invocation
|
||||
|
||||
```
|
||||
# Interactive
|
||||
"Create a new band profile"
|
||||
"Analyze my writing voice for the midnight-echoes profile"
|
||||
"Health check the velvet-haze profile"
|
||||
|
||||
# Headless
|
||||
--headless:create < profile.yaml
|
||||
--headless:validate --profile midnight-echoes
|
||||
--headless:edit midnight-echoes --field tier=pro
|
||||
```
|
||||
|
||||
## Profiles Storage
|
||||
|
||||
Profiles are stored as YAML files at `docs/band-profiles/{profile-name}.yaml`. The schema is defined in `./references/profile-schema.md`.
|
||||
|
||||
## 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,253 @@
|
||||
# Band Profile Schema
|
||||
|
||||
## YAML Structure
|
||||
|
||||
```yaml
|
||||
# Band Profile — {band_name}
|
||||
# Created: {date}
|
||||
# Last modified: {date}
|
||||
|
||||
name: "Band Name Here"
|
||||
version: 1 # Increment on major sound evolution
|
||||
instrumental: false # true for instrumental-only projects (skips vocal requirements)
|
||||
|
||||
# Sound Identity
|
||||
genre: "indie folk-rock with electronic textures"
|
||||
mood: "melancholic but hopeful, atmospheric"
|
||||
language: "English" # Language for lyrics and style cues
|
||||
reference_tracks:
|
||||
- "Bon Iver meets Radiohead"
|
||||
- "Fleet Foxes with Massive Attack production"
|
||||
|
||||
# Model & Tier
|
||||
model_preference: "v4.5-all" # v4.5-all | v4 Pro (legacy) | v4.5 Pro | v4.5+ Pro | v5 Pro | v5.5
|
||||
tier: "free" # free | pro | premier
|
||||
|
||||
# Style Prompt — 1,000 char limit (v4.5+/v5/v5.5; 200 for v4 Pro). Front-load essentials in first ~200 chars (critical zone).
|
||||
style_baseline: >
|
||||
Indie folk-rock with electronic textures, atmospheric and layered.
|
||||
Warm analog synths underneath acoustic guitar, subtle ambient pads.
|
||||
Modern production, wide stereo field, intimate mix.
|
||||
exclusion_defaults:
|
||||
- "no autotune"
|
||||
- "no screaming"
|
||||
- "no heavy metal guitar"
|
||||
|
||||
# Vocal Direction (required unless instrumental: true)
|
||||
vocal:
|
||||
gender: "male" # male | female | nonbinary | any
|
||||
tone: "warm, breathy"
|
||||
delivery: "intimate, conversational"
|
||||
energy: "restrained, building"
|
||||
diction: "clear, slightly slurred on emotional peaks"
|
||||
persona_reference: "" # Suno Persona name, if exists (v4.5/v5 only; replaced by voice_id in v5.5)
|
||||
persona_source_song: "" # Song the Persona was derived from (for recreation)
|
||||
# NOTE: Personas pull the sound toward the era/style of the source song.
|
||||
# Audio Influence at 10-15% reduces this era-anchoring but doesn't fully
|
||||
# overcome it. For era-specific pieces, consider generating without a persona,
|
||||
# or creating era-specific personas from era-appropriate source songs.
|
||||
voice_id: "" # Suno Voice identifier (v5.5, Pro/Premier only). Replaces persona_reference for v5.5.
|
||||
# NOTE: When voice_id is set, omit gender vocal descriptors from style_baseline —
|
||||
# the Voice defines the vocal identity (gender, tone, character from the audio sample).
|
||||
|
||||
# Creative Settings
|
||||
creativity_default: "balanced" # conservative | balanced | experimental
|
||||
|
||||
# Sliders (pay-gated — only set if tier supports them)
|
||||
sliders:
|
||||
weirdness: 50 # 0-100, default 50
|
||||
style_influence: 50 # 0-100, default 50
|
||||
audio_influence: null # 0-100, only when using audio upload
|
||||
|
||||
# Studio Preferences (Premier tier only)
|
||||
studio_preferences:
|
||||
bpm: null # Default tempo (number)
|
||||
key: "" # Default key/scale (e.g., "C minor", "A major")
|
||||
time_signature: "" # Default time signature (e.g., "4/4", "3/4")
|
||||
|
||||
# Custom Model (v5.5, Pro/Premier only)
|
||||
custom_model_id: "" # Suno Custom Model identifier, if user has one
|
||||
custom_model_notes: "" # What the custom model was trained on and what production style it provides
|
||||
|
||||
# Writer Voice (optional — populated by Analyze Writer Voice)
|
||||
writer_voice:
|
||||
vocabulary: "" # formal/casual, abstract/concrete, domain words
|
||||
rhythm: "" # sentence length patterns, fragment use
|
||||
imagery: "" # dominant image worlds (nature, urban, body, etc.)
|
||||
emotional_tone: "" # raw/restrained, hopeful/melancholic, etc.
|
||||
metaphor_style: "" # extended/quick, conventional/surprising, frequency
|
||||
repetition_patterns: "" # anaphora, refrains, echo structures
|
||||
sample_quotes: [] # representative lines from analyzed samples
|
||||
|
||||
# Known Working Prompt Patterns (optional — prompt formulations that reliably produce good results)
|
||||
known_working_patterns: []
|
||||
# Per-profile list of prompt patterns proven to work well for this band's sound.
|
||||
# Record specific formulations that nail the identity, especially when blending genres.
|
||||
# Examples:
|
||||
# - "'atmospheric swamp metal accents' — best formulation for keeping band identity when another genre leads"
|
||||
# - "'progressive heavy groove with post-rock dynamics' — captures heaviness without triggering screaming"
|
||||
|
||||
# Known Limitations (optional — things Suno can't reliably do for this sound)
|
||||
known_limitations: []
|
||||
# Per-profile list of known limitations or failure modes for this band's genre/style.
|
||||
# Saves time by documenting dead ends and workaround-required areas.
|
||||
# Examples:
|
||||
# - "Bass-forward rock/metal is not reliably achievable — Suno defaults to guitar-forward mixes"
|
||||
# - "'funk metal' triggers slap/pop bass, not overdriven fingerstyle — avoid this term"
|
||||
# - "Even with 'guitar' in Exclude Styles, Suno still produces guitar in rock/metal context"
|
||||
|
||||
# Generation Learnings (optional — what prompt language triggers what behavior)
|
||||
generation_learnings:
|
||||
# Optional — captures what style prompt language triggers what behavior
|
||||
# for this specific band's sound. Accumulated from testing and feedback.
|
||||
# Examples:
|
||||
# - "'metal' in style prompt triggers screaming — use 'progressive heavy groove' instead"
|
||||
# - "'sludge' triggers harsh vocals — use 'thick, heavy' instead"
|
||||
# - "Weirdness above 60 produces inconsistent results for this genre"
|
||||
|
||||
# Generation History (optional — successful generation snapshots)
|
||||
generation_history: []
|
||||
# Each entry:
|
||||
# - date: "2026-03-19"
|
||||
# style_prompt: "the style prompt that worked"
|
||||
# model: "v5 Pro"
|
||||
# sliders: { weirdness: 65, style_influence: 55 }
|
||||
# note: "nailed the vocal tone on this one"
|
||||
```
|
||||
|
||||
## Field Definitions
|
||||
|
||||
| Field | Required | Type | Constraints |
|
||||
|-------|----------|------|-------------|
|
||||
| `name` | Yes | string | Non-empty, used as display name |
|
||||
| `version` | No | integer | Defaults to 1, increment on major changes |
|
||||
| `instrumental` | No | boolean | Defaults to false. When true, vocal fields become optional |
|
||||
| `genre` | Yes | string | Non-empty |
|
||||
| `mood` | Yes | string | Non-empty |
|
||||
| `language` | No | string | Defaults to "English". Passed to Lyric Transformer and Style Prompt Builder |
|
||||
| `reference_tracks` | No | list of strings | Free-form "sounds like" descriptions |
|
||||
| `model_preference` | Yes | string | One of: v4.5-all, v4 Pro (legacy), v4.5 Pro, v4.5+ Pro, v5 Pro, v5.5 |
|
||||
| `tier` | Yes | string | One of: free, pro, premier |
|
||||
| `style_baseline` | Yes | string | Max 1000 chars (v4.5+/v5/v5.5). Max 200 chars for v4 Pro. Front-load essentials in first ~200 chars (critical zone — strongest influence). Content beyond 200 is supplementary, not wasted. |
|
||||
| `exclusion_defaults` | No | list of strings | Keep each entry concise and specific. Max 5 entries recommended |
|
||||
| `vocal.gender` | Yes* | string | One of: male, female, nonbinary, any. *Optional if `instrumental: true` |
|
||||
| `vocal.tone` | Yes* | string | Non-empty. *Optional if `instrumental: true` |
|
||||
| `vocal.delivery` | Yes* | string | Non-empty. *Optional if `instrumental: true` |
|
||||
| `vocal.energy` | Yes* | string | Non-empty. *Optional if `instrumental: true` |
|
||||
| `vocal.diction` | No | string | Optional refinement |
|
||||
| `vocal.persona_reference` | No | string | Suno Persona name if exists (Pro/Premier only). v4.5/v5 models only; replaced by `voice_id` for v5.5 |
|
||||
| `vocal.persona_source_song` | No | string | Song the Persona was derived from (for recreation if lost) |
|
||||
| `vocal.voice_id` | No | string | Suno Voice identifier (Pro/Premier only, v5.5). Replaces `persona_reference` for v5.5. When set, omit gender vocal descriptors from `style_baseline` |
|
||||
| `creativity_default` | No | string | One of: conservative, balanced, experimental. Defaults to balanced |
|
||||
| `sliders.weirdness` | No | integer | 0-100, only valid for pro/premier tiers |
|
||||
| `sliders.style_influence` | No | integer | 0-100, only valid for pro/premier tiers |
|
||||
| `sliders.audio_influence` | No | integer | 0-100, only appears when using audio upload (pro/premier) |
|
||||
| `studio_preferences.bpm` | No | number | Default tempo. Only valid for premier tier |
|
||||
| `studio_preferences.key` | No | string | Default key/scale. Only valid for premier tier |
|
||||
| `studio_preferences.time_signature` | No | string | Default time signature. Only valid for premier tier |
|
||||
| `custom_model_id` | No | string | Suno Custom Model identifier (Pro/Premier only, v5.5). Up to 3 models per account, trained on 6+ original tracks |
|
||||
| `custom_model_notes` | No | string | Description of what the custom model was trained on and what production style it provides |
|
||||
| `writer_voice.*` | No | string/list | All writer_voice fields are optional |
|
||||
| `known_working_patterns` | No | list of strings | Prompt formulations proven to reliably produce good results for this band's sound. Record specific wording that nails the identity. |
|
||||
| `known_limitations` | No | list of strings | Known failure modes or dead ends for this band's genre/style in Suno. Saves time by documenting things that don't work. |
|
||||
| `generation_learnings` | No | list of strings | Accumulated observations about what prompt language triggers what Suno behavior for this band's genre/style. Updated from testing and feedback sessions. |
|
||||
| `generation_history` | No | list of objects | Max 10 entries. Each entry: date, style_prompt, model, sliders, note |
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. `name` must be non-empty
|
||||
2. `genre` must be non-empty
|
||||
3. `mood` must be non-empty
|
||||
4. `model_preference` must be one of the allowed values
|
||||
5. `tier` must be one of: free, pro, premier
|
||||
6. `style_baseline` must not exceed 1000 characters (200 for v4 Pro)
|
||||
7. If `instrumental` is not true: `vocal.gender` must be one of: male, female, nonbinary, any
|
||||
8. If `instrumental` is not true: `vocal.tone`, `vocal.delivery`, `vocal.energy` must be non-empty
|
||||
9. If `instrumental` is true: vocal section is optional; if present, fields are not required
|
||||
10. If `tier` is "free", `sliders` should not be present or should warn that values won't be usable
|
||||
11. If `tier` is "free" and `model_preference` is not "v4.5-all", warn about mismatch
|
||||
12. If `tier` is not "premier" and `studio_preferences` has values, warn they won't be usable
|
||||
13. If `creativity_default` is present, must be one of: conservative, balanced, experimental
|
||||
14. If `language` is present, must be a non-empty string
|
||||
15. `generation_history` must not exceed 10 entries
|
||||
16. Profile filename must be kebab-case matching the band name (spaces to hyphens, lowercase)
|
||||
17. If `vocal.voice_id` is set, warn if `vocal.gender` is also set — the Voice defines vocal identity, gender descriptors should be omitted from `style_baseline`
|
||||
18. If `vocal.voice_id` is set but `model_preference` is not "v5.5", warn that Voices require v5.5
|
||||
19. If `custom_model_id` is set but `tier` is "free", warn that Custom Models require Pro or Premier tier
|
||||
|
||||
## Notes for Downstream Skills
|
||||
|
||||
- **Style Prompt Builder** reads: `style_baseline`, `reference_tracks`, `vocal`, `exclusion_defaults`, `sliders`, `creativity_default`, `model_preference`, `language`, `instrumental`
|
||||
- **Lyric Transformer** reads: `writer_voice`, `language`
|
||||
- **Feedback Elicitor** reads: `style_baseline`, `sliders`, `model_preference`; writes to `generation_history` via headless:edit
|
||||
- When a Persona is active (v4.5/v5), its style auto-populates the Style of Music field — keep additional style modifications simple (1-2 genres, 1 mood, 2-4 instruments max)
|
||||
- **Persona Era-Anchoring (v4.5/v5):** Personas pull the sound toward the era/style of the source song. Audio Influence at 10-15% reduces this but doesn't eliminate it. For era-specific pieces, generate without a persona or create era-specific personas from era-appropriate source songs.
|
||||
- **Voices (v5.5):** Voices replace Personas for v5.5. When `voice_id` is set, the Voice defines the vocal identity — omit gender vocal descriptors from `style_baseline`. The style prompt should focus on instrumentation, production, and mood rather than vocal character.
|
||||
- **v5.5 Voice Gravity Principle (validated April 2026):** v5.5 Voice clones carry **trained genre gravity** — the Voice pulls generations toward its trained baseline on its own. When the target song genre differs from the Voice's trained direction, the style prompt must ACTIVELY FIGHT that gravity, not describe the target. Six practical rules for Voice-aware profiles (see `suno-style-prompt-builder/references/model-prompt-strategies.md` for full details and validated case study):
|
||||
1. **Drop descriptors the Voice already delivers** — if the Voice is a folk clone, drop "warm," "vulnerable," "clean," "storytelling vocal" from `style_baseline`. These are wasted characters and can fight the Voice.
|
||||
2. **Load descriptors that push AGAINST the Voice's direction** — for a folk Voice doing rock songs, lean hard into "overdriven," "crunch," "driving groove," "rock urgency."
|
||||
3. **Keep Style Influence at 65+** so the prompt leads firmly. Profiles with a Voice-genre mismatch should bump `sliders.style_influence` to 65 as the default.
|
||||
4. **Leave `vocal.gender` empty** when `voice_id` is set — the schema already warns about this (rule 17).
|
||||
5. **Voice-aware `exclusion_defaults`** — when the Voice physically cannot produce harsh vocals, drop `harsh vocals`, `screamed vocals`, etc. from exclusions. Focus exclusions on production/genre-direction protection only (`heavy metal`, `heavy distortion`, `steel guitar`, `autotune`, `pop sheen`). The clean Voice IS the guardrail.
|
||||
6. **Audio Influence floor** — use 55-60% as the default for Voice profiles. 30-40% "subtle flavor" only works with Professional-level Voices; non-Professional Voices below 40% trigger robotic timbre.
|
||||
- **Multi-profile Voice strategy** — profiles can reference multiple Voice IDs when the project uses several Voice recordings (e.g., "Narrative Rock" for mid-tempo rock tracks, "Ballad Intimate" for tender songs, "Speak-Sing Confessional" for literary/narrative tracks). Each Voice should be internally consistent (single stable character, 20-30 sec per recording, Skill Level Professional mandatory). Variety lives across Voices, not within one Voice sample. Document the mapping and per-Voice use cases in the profile.
|
||||
- **Custom Models (v5.5):** When `custom_model_id` is set, the Style Prompt Builder should complement the model's learned production style rather than fight it. Include `custom_model_notes` context when building prompts.
|
||||
- **Inspo Playlist Guidance:** Using your own songs as Inspo homogenizes the catalog sound. Drop Inspo when a song needs its own identity within the same band — let the style prompt and persona/voice do the work instead.
|
||||
|
||||
---
|
||||
|
||||
## Per-Band Playlist YAML (the canonical playlist source)
|
||||
|
||||
Each band in the project owns exactly **one** canonical playlist file:
|
||||
|
||||
```
|
||||
docs/{band-slug}-playlist.yaml
|
||||
```
|
||||
|
||||
The slug matches the band profile filename — `docs/band-profiles/solitary-fire.yaml` pairs with `docs/solitary-fire-playlist.yaml`. This file is the single source of truth for the band's track sequence; **do not duplicate the track list elsewhere.** Other files (sidecar narrative, voice context, ordering doc) reference or derive from this YAML.
|
||||
|
||||
### Schema
|
||||
|
||||
```yaml
|
||||
album: "<Band display name>"
|
||||
tracks:
|
||||
- name: "<Song title (must match the songbook entry's frontmatter title)>"
|
||||
file: "<exact filename in docs/audio/, e.g. My Song.mp3>"
|
||||
- name: "<next song>"
|
||||
file: "<next file>"
|
||||
# ...
|
||||
```
|
||||
|
||||
The two required fields per track are `name` (the human-readable song title — must match the songbook entry's frontmatter `title`) and `file` (the audio filename in `docs/audio/`, used as the input to `playlist-sequencing-data.py`).
|
||||
|
||||
### Why this file exists
|
||||
|
||||
The audio analysis script (`playlist-sequencing-data.py`) needs a per-band ordered list with audio file mappings. Without a canonical YAML, this list inevitably gets duplicated in several places — the band profile YAML, an ordering doc, the sidecar narrative, the voice context — and each copy drifts independently. Consolidating to a single file with a deterministic location ends the drift class.
|
||||
|
||||
### Bootstrapping
|
||||
|
||||
If a band already has songbook entries but no playlist YAML, scaffold one:
|
||||
|
||||
```bash
|
||||
python3 src/skills/suno-band-profile-manager/scripts/scaffold-playlist.py {band-slug} --from-songbook
|
||||
```
|
||||
|
||||
This writes `docs/{band-slug}-playlist.yaml` with discovered song titles populated and `file:` fields left as empty strings (TODO: fill in from `docs/audio/`). The user reviews, fills in audio filenames, sets the order, and saves.
|
||||
|
||||
For a brand new band with no songbook entries yet, run without `--from-songbook` to write an empty template.
|
||||
|
||||
### Auto-creation on band profile creation
|
||||
|
||||
When a new band profile is created via `suno-band-profile-manager`, the playlist YAML scaffold MUST be created in the same write batch. New bands without a playlist YAML are caught by `validate-profile.py` once they have any songbook entries.
|
||||
|
||||
### Deprecation notice — the `playlist:` block in band profile YAML
|
||||
|
||||
Earlier versions of this module supported a `playlist:` block inside the band profile YAML carrying track order, sequencing notes, and gap analysis. As of v1.7.2, **that block is deprecated and validators will warn on profiles that still carry it.** Move authoritative track-list data to `docs/{band-slug}-playlist.yaml`; sequencing-history narrative notes can move to a band-specific ordering doc (`docs/{band-slug}-playlist-ordering.md`) if you maintain one. Keeping playlist data in two places is the drift problem this convention was created to fix.
|
||||
|
||||
### Workflow rules (apply in same write batch)
|
||||
|
||||
- **On song publish:** the band's `docs/{band-slug}-playlist.yaml` MUST be updated alongside the songbook entry.
|
||||
- **On track reorder:** edit `docs/{band-slug}-playlist.yaml` first; the script's per-album companion `docs/{band-slug}-playlist-sequencing.md` auto-refreshes from this on the next run.
|
||||
- **On track removal/rename:** update the YAML, the songbook (if renaming), the sidecar narrative, and any ordering doc all in the same write batch.
|
||||
|
||||
See also `suno-feedback-elicitor/references/playlist-sequencing-methodology.md` for the album-craft methodology that consumes this file's data.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Suno Tier Feature Matrix
|
||||
|
||||
> **Last validated:** March 2026 (Suno Free, Pro, Premier plans). Suno updates pricing, features, and tier boundaries frequently — use web search to verify against current Suno pricing page when uncertain.
|
||||
|
||||
**Note:** The `./scripts/tier-features.py` script is the authoritative source for this data. This reference file is provided for human readability. When updating, update the script first.
|
||||
|
||||
## Plan Comparison
|
||||
|
||||
| Feature | Free ($0) | Pro ($10/mo, $8/mo annual) | Premier ($30/mo, $24/mo annual) |
|
||||
|---------|-----------|----------------------------|----------------------------------|
|
||||
| **Model Access** | v4.5-all only | All models incl. v5, v5.5 | All models incl. v5.5 + Studio |
|
||||
| **Credits** | 50/day (~10 songs) | 2,500/mo (~500 songs) | 10,000/mo (~2,000 songs) |
|
||||
| **Credit Cost** | 10/song, 5/extend | 10/song, 5/extend | 10/song, 5/extend |
|
||||
| **Song Length** | Determined by model — v4.5-all supports up to ~8 min | Determined by model — v4.5/v5 support up to ~8 min | Determined by model — v4.5/v5 support up to ~8 min |
|
||||
| **Download Quality** | 128kbps MP3 | 320kbps MP3 + WAV | 320kbps MP3 + WAV |
|
||||
| **Commercial Use** | No | Yes (new songs) | Yes (new songs) |
|
||||
| **Personas** | No | Yes (v4.5/v5 only; replaced by Voices in v5.5) | Yes (v4.5/v5 only; replaced by Voices in v5.5) |
|
||||
| **Voices** | No | Yes (v5.5 voice cloning) | Yes (v5.5 voice cloning) |
|
||||
| **Custom Models** | No | Yes (up to 3 models) | Yes (up to 3 models) |
|
||||
| **My Taste** | Yes (passive) | Yes (passive) | Yes (passive) |
|
||||
| **Weirdness Slider** | No | Yes (0-100) | Yes (0-100) |
|
||||
| **Style Influence Slider** | No | Yes (0-100) | Yes (0-100) |
|
||||
| **Audio Influence Slider** | No | Yes (0-100, with audio upload) | Yes (0-100, with audio upload) |
|
||||
| | | *10-15% reduces persona era-anchoring* | *10-15% reduces persona era-anchoring* |
|
||||
| **Add Vocals/Instrumental** | No | Yes (beta) | Yes (beta) |
|
||||
| **Covers** | No | Yes (beta) | Yes (beta) |
|
||||
| **Remaster** | No | Yes | Yes |
|
||||
| **Stems** | No | Up to 12 | Up to 12 |
|
||||
| **Audio Upload** | 1 min | 8 min | 8 min |
|
||||
| **Legacy Editor** (Replace, Extend, Crop, Fade, Rearrange) | No | Yes | Yes |
|
||||
| **Studio** (full Generative Audio Workstation) | No | No | Yes |
|
||||
| **Warp Markers** | No | No | Yes (Studio) |
|
||||
| **Remove FX** | No | No | Yes (Studio) |
|
||||
| **Alternates / Take Lanes** | No | No | Yes (Studio) |
|
||||
| **EQ** (6-band per track) | No | No | Yes (Studio) |
|
||||
| **Time Signature** control | No | No | Yes (Studio, editing only — not sent to generative models) |
|
||||
| **Context Window** | No | No | Yes (Studio) |
|
||||
| **Recording** (microphone) | No | No | Yes (Studio) |
|
||||
| **Loop Recording** | No | No | Yes (Studio) |
|
||||
| **Sounds Mode** (text-to-sound) | No | No | Yes (Studio, beta) |
|
||||
| **Stem Cover** | No | No | Yes (Studio) |
|
||||
| **Heal Edits** | No | No | Yes (Studio) |
|
||||
| **MIDI Export** (10 credits/stem) | No | No | Yes |
|
||||
| **MILO-1080 Sequencer** | No | No | Yes (Studio) |
|
||||
| **Queue Priority** | Shared | Priority, 10 at once | Priority, 10 at once |
|
||||
| **Add-on Credits** | No | Yes | Yes |
|
||||
|
||||
## Free Tier Available Options
|
||||
|
||||
- Vocal Gender selection
|
||||
- Manual/Auto Lyrics mode
|
||||
- Song Title
|
||||
|
||||
## Models
|
||||
|
||||
| Model | Tagline | Availability |
|
||||
|-------|---------|-------------|
|
||||
| v5.5 | Voices, Custom Models, My Taste | Pro/Premier |
|
||||
| v5 Pro | Authentic vocals, superior audio quality and control | Pro/Premier |
|
||||
| v4.5+ Pro | Advanced creation methods | Pro/Premier |
|
||||
| v4.5 Pro | Intelligent prompts | Pro/Premier |
|
||||
| v4.5-all | Best free model | All tiers |
|
||||
| v4 Pro | Improved sound quality (legacy) | Pro/Premier |
|
||||
|
||||
## Profile Implications by Tier
|
||||
|
||||
**Free tier profiles should:**
|
||||
- Set `model_preference` to "v4.5-all" (only available model)
|
||||
- Omit or zero out `sliders` (not available)
|
||||
- Not reference Personas or Voices (not available)
|
||||
- Focus style_baseline on conversational descriptions (v4.5-all strength)
|
||||
- My Taste is active passively — no profile configuration needed
|
||||
|
||||
**Pro tier profiles can:**
|
||||
- Use any model including v5 Pro and v5.5
|
||||
- Set Weirdness and Style Influence sliders
|
||||
- Reference Suno Personas for vocal consistency (v4.5/v5 models)
|
||||
- Use Suno Voices for vocal consistency (v5.5 model — replaces Personas)
|
||||
- Use Custom Models (up to 3, trained on 6+ original tracks, 2-5 min training time)
|
||||
- Use crisp, descriptor-focused style for v5 Pro
|
||||
- Use Audio Influence slider to manage persona era-anchoring (reduce to 10-15% when the persona's source era conflicts with the desired sound)
|
||||
- When a Voice is configured, omit gender vocal descriptors from style_baseline — the Voice defines the vocal identity
|
||||
|
||||
**Premier tier profiles can:**
|
||||
- Everything Pro can do, plus full Suno Studio (GAW)
|
||||
- Set studio_preferences (BPM, key, time signature)
|
||||
- Stems separation for production work
|
||||
- MIDI export for DAW integration (10 credits per stem)
|
||||
- Voices and Custom Models (same as Pro)
|
||||
- EQ (6-band per track), Warp Markers, Remove FX, Alternates, Context Window
|
||||
- Recording (microphone input), Loop Recording, Sounds Mode, Stem Cover, Heal Edits
|
||||
- MILO-1080 Step Sequencer (16-track, text-to-sound, MIDI I/O)
|
||||
|
||||
## Production Notes
|
||||
|
||||
**Audio Influence as Era Control (Pro/Premier):** When a persona's era-anchoring conflicts with the desired era for a track, reducing Audio Influence from the default 25% to 10-15% helps pull the sound away from the persona's source era. This doesn't fully eliminate the anchoring — for strong era shifts, consider generating without a persona or creating an era-specific persona from an era-appropriate source song.
|
||||
|
||||
**Audio Influence Effective Range (Pro/Premier):** The practical range for Audio Influence is 15-25%. Values above 25% show diminishing returns — tested at 40%, it did not override an incompatible style prompt. The slider shapes the persona's contribution but cannot force the persona's character over a conflicting style direction.
|
||||
|
||||
**Acoustic/Ballad Tracks and Audio Influence (Pro/Premier):** When the style prompt clearly defines a non-heavy genre (ballad, acoustic, stripped-back), the persona contributes only vocal identity — it does not drag in unwanted instrumentation. Do NOT reduce Audio Influence for ballads or stripped tracks; keep it at the normal working range. The style prompt governs the arrangement; the persona governs the voice.
|
||||
|
||||
**Exclude Styles — Known Limitations:** The Exclude Styles field helps shape tone but does not reliably remove instruments entirely. For example, even with "guitar" in Exclude Styles, Suno still produces guitar in rock/metal contexts. Treat Exclude Styles as a nudge toward the desired balance rather than a hard instrument filter.
|
||||
|
||||
**Personas to Voices Transition (v5.5):** Personas are replaced by Voices in v5.5. Existing Personas still work on v4.5 and v5 models. For v5.5 generation, use a Voice instead. Voices are created from a 15-second to 4-minute audio sample and include anti-deepfake verification. Voices are private to the account that created them.
|
||||
|
||||
**Voices and Vocal Descriptors (v5.5, Pro/Premier):** When a Voice is active, the Voice defines the vocal identity — gender, tone, and character come from the audio sample. Omit gender vocal descriptors from the style prompt to avoid conflicts. Other vocal direction (delivery, energy, diction) can still shape performance.
|
||||
|
||||
**Audio Influence with Voices (v5.5, Pro/Premier):** Unlike Personas (15-25% effective range), Voices uses a wider range. The sweet spot is personal — 35-45% for subtle flavor, 55-70% balanced (default starting point), 75-85% for identity-focused work, 85-95% for maximum fidelity. Adjust up if voice is unrecognizable, down if quality suffers.
|
||||
|
||||
**Custom Models (v5.5, Pro/Premier):** Custom Models are trained on 6 or more original tracks and take 2-5 minutes to train. Up to 3 Custom Models per account. They capture a production style and sound signature. When a Custom Model is active, it shapes the overall production character — the style prompt should complement rather than fight the model's learned style.
|
||||
|
||||
**My Taste (v5.5, All Tiers):** My Taste is passive personalization derived from the user's generation history. It requires no configuration and works across all tiers including Free. It subtly shapes generation output based on patterns in what the user has created and liked.
|
||||
|
||||
**Legacy Editor vs. Studio (Pro vs Premier):** Pro users get the Legacy Editor — section-level editing with Replace Section, Extend, Crop, Fade, Rearrange, and Stems. Premier users additionally get Suno Studio — a full browser-based Generative Audio Workstation with multitrack timeline, EQ, Warp Markers, Alternates/Take Lanes, Remove FX, Recording, Loop Recording, Context Window, Stem Cover, Sounds Mode, Heal Edits, and MIDI Export. For complete editing workflows, see [STUDIO-EDITOR-REFERENCE.md](../../STUDIO-EDITOR-REFERENCE.md).
|
||||
|
||||
**Remaster (Pro/Premier):** Generates refined variations adjusting production details (instrument balance, effects, mix quality, vocal clarity) while preserving song structure. Three strength levels: Subtle, Normal, High. Does NOT change lyrics, style, or vocalist — use Cover for those. Good for final polish before export.
|
||||
|
||||
**Replace Section Best Practices (Pro/Premier):** Key controls: Keep Duration toggle (ON = match length, OFF = creative flexibility), Instrumental Mode toggle (removes vocals), Replace Lyrics (edit lyrics for just the selected region). Best results with 10-30 second selections; typically requires 2-5 attempts for seamless transitions.
|
||||
|
||||
**v5.5 Editing Paradigm:** v5.5 favors generate → inspect → section replace → refine (not regenerate from scratch). This preserves good material and spends fewer credits. For complete Studio and Editor workflows, see [STUDIO-EDITOR-REFERENCE.md](../../suno-agent-band-manager/references/STUDIO-EDITOR-REFERENCE.md).
|
||||
160
.agent/skills/suno-band-profile-manager/scripts/diff-profiles.py
Normal file
160
.agent/skills/suno-band-profile-manager/scripts/diff-profiles.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml>=6.0"]
|
||||
# ///
|
||||
|
||||
"""Compare two band profile YAML files and return structured differences.
|
||||
|
||||
Takes an original and modified profile, compares field-by-field,
|
||||
and returns a structured JSON diff showing changed, added, and
|
||||
removed fields. Handles nested structures (vocal, sliders, etc.).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def flatten_dict(d: dict, prefix: str = "") -> dict:
|
||||
"""Flatten a nested dict into dot-notation keys."""
|
||||
items = {}
|
||||
for k, v in d.items():
|
||||
key = f"{prefix}.{k}" if prefix else k
|
||||
if isinstance(v, dict):
|
||||
items.update(flatten_dict(v, key))
|
||||
else:
|
||||
items[key] = v
|
||||
return items
|
||||
|
||||
|
||||
def diff_profiles(original_path: Path, modified_path: Path) -> dict:
|
||||
"""Compare two profile YAML files and return structured diff."""
|
||||
script_name = "diff-profiles"
|
||||
errors = []
|
||||
|
||||
for label, path in [("original", original_path), ("modified", modified_path)]:
|
||||
if not path.exists():
|
||||
errors.append(f"{label} file does not exist: {path}")
|
||||
|
||||
if errors:
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
try:
|
||||
with open(original_path) as f:
|
||||
original = yaml.safe_load(f) or {}
|
||||
with open(modified_path) as f:
|
||||
modified = yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"errors": [f"YAML parse error: {e}"],
|
||||
}
|
||||
|
||||
if not isinstance(original, dict) or not isinstance(modified, dict):
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"errors": ["Both files must be YAML mappings"],
|
||||
}
|
||||
|
||||
flat_orig = flatten_dict(original)
|
||||
flat_mod = flatten_dict(modified)
|
||||
|
||||
all_keys = set(flat_orig.keys()) | set(flat_mod.keys())
|
||||
|
||||
changed = []
|
||||
added = []
|
||||
removed = []
|
||||
|
||||
for key in sorted(all_keys):
|
||||
in_orig = key in flat_orig
|
||||
in_mod = key in flat_mod
|
||||
|
||||
if in_orig and in_mod:
|
||||
if flat_orig[key] != flat_mod[key]:
|
||||
changed.append({
|
||||
"field": key,
|
||||
"old": flat_orig[key],
|
||||
"new": flat_mod[key],
|
||||
})
|
||||
elif in_mod and not in_orig:
|
||||
added.append({
|
||||
"field": key,
|
||||
"value": flat_mod[key],
|
||||
})
|
||||
elif in_orig and not in_mod:
|
||||
removed.append({
|
||||
"field": key,
|
||||
"value": flat_orig[key],
|
||||
})
|
||||
|
||||
has_changes = bool(changed or added or removed)
|
||||
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "1.0.0",
|
||||
"original": str(original_path),
|
||||
"modified": str(modified_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"has_changes": has_changes,
|
||||
"changed": changed,
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"summary": {
|
||||
"total_changes": len(changed) + len(added) + len(removed),
|
||||
"fields_changed": len(changed),
|
||||
"fields_added": len(added),
|
||||
"fields_removed": len(removed),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare two band profile YAML files and return a structured diff.",
|
||||
epilog="Exit codes: 0=success, 1=fail"
|
||||
)
|
||||
parser.add_argument("original", help="Path to the original profile YAML file")
|
||||
parser.add_argument("modified", help="Path to the modified profile YAML file")
|
||||
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
args = parser.parse_args()
|
||||
|
||||
original_path = Path(args.original)
|
||||
modified_path = Path(args.modified)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Comparing: {original_path} -> {modified_path}", file=sys.stderr)
|
||||
|
||||
result = diff_profiles(original_path, modified_path)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
if args.verbose:
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
sys.exit(0 if result["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
167
.agent/skills/suno-band-profile-manager/scripts/list-profiles.py
Normal file
167
.agent/skills/suno-band-profile-manager/scripts/list-profiles.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml>=6.0"]
|
||||
# ///
|
||||
|
||||
"""List all band profiles in docs/band-profiles/.
|
||||
|
||||
Scans the directory for YAML files, extracts key fields from each,
|
||||
and returns a structured JSON summary. Supports --check for single
|
||||
profile existence verification.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def check_profile(profiles_dir: Path, profile_name: str) -> dict:
|
||||
"""Check if a specific profile exists and return its metadata."""
|
||||
script_name = "list-profiles"
|
||||
|
||||
# Try exact filename first, then derive from name
|
||||
candidates = [
|
||||
profiles_dir / profile_name,
|
||||
profiles_dir / f"{profile_name}.yaml",
|
||||
profiles_dir / f"{profile_name}.yml",
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists() and candidate.is_file():
|
||||
stat = candidate.stat()
|
||||
try:
|
||||
with open(candidate) as f:
|
||||
data = yaml.safe_load(f)
|
||||
name = data.get("name", candidate.stem) if isinstance(data, dict) else candidate.stem
|
||||
except (yaml.YAMLError, OSError):
|
||||
name = candidate.stem
|
||||
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"exists": True,
|
||||
"file": candidate.name,
|
||||
"path": str(candidate),
|
||||
"name": name,
|
||||
"size_bytes": stat.st_size,
|
||||
"last_modified": datetime.fromtimestamp(
|
||||
stat.st_mtime, tz=timezone.utc
|
||||
).isoformat(),
|
||||
}
|
||||
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"exists": False,
|
||||
"query": profile_name,
|
||||
"profiles_dir": str(profiles_dir),
|
||||
}
|
||||
|
||||
|
||||
def list_profiles(profiles_dir: Path) -> dict:
|
||||
"""Scan profiles directory and return structured summary."""
|
||||
script_name = "list-profiles"
|
||||
|
||||
if not profiles_dir.exists():
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"profiles_dir": str(profiles_dir),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"profiles": [],
|
||||
"count": 0,
|
||||
"message": "No profiles directory found. No band profiles have been created yet."
|
||||
}
|
||||
|
||||
yaml_files = sorted(profiles_dir.glob("*.yaml")) + sorted(profiles_dir.glob("*.yml"))
|
||||
profiles = []
|
||||
|
||||
for yf in yaml_files:
|
||||
try:
|
||||
with open(yf) as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
profiles.append({
|
||||
"file": yf.name,
|
||||
"name": data.get("name", yf.stem),
|
||||
"genre": data.get("genre", "unknown"),
|
||||
"mood": data.get("mood", ""),
|
||||
"model_preference": data.get("model_preference", "unknown"),
|
||||
"tier": data.get("tier", "unknown"),
|
||||
"instrumental": data.get("instrumental", False),
|
||||
"language": data.get("language", "English"),
|
||||
"creativity_default": data.get("creativity_default", "balanced"),
|
||||
"has_writer_voice": bool(data.get("writer_voice", {}).get("vocabulary")),
|
||||
"has_generation_history": bool(data.get("generation_history")),
|
||||
"version": data.get("version", 1)
|
||||
})
|
||||
except (yaml.YAMLError, OSError) as e:
|
||||
print(f"Warning: Could not read {yf}: {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"profiles_dir": str(profiles_dir),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"profiles": profiles,
|
||||
"count": len(profiles)
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="List all band profiles in a directory.",
|
||||
epilog="Exit codes: 0=success, 2=error"
|
||||
)
|
||||
parser.add_argument(
|
||||
"profiles_dir",
|
||||
nargs="?",
|
||||
default="docs/band-profiles",
|
||||
help="Path to band profiles directory (default: docs/band-profiles)"
|
||||
)
|
||||
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
metavar="PROFILE_NAME",
|
||||
help="Check if a specific profile exists and return its metadata"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
profiles_dir = Path(args.profiles_dir)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Scanning: {profiles_dir}", file=sys.stderr)
|
||||
|
||||
if args.check:
|
||||
result = check_profile(profiles_dir, args.check)
|
||||
else:
|
||||
result = list_profiles(profiles_dir)
|
||||
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
if args.verbose:
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml>=6.0"]
|
||||
# ///
|
||||
"""Scaffold a per-band playlist YAML.
|
||||
|
||||
Each band in the project owns exactly one canonical
|
||||
`docs/{band-slug}-playlist.yaml` that lists the tracks in their playlist
|
||||
order with a name → audio-file mapping. This file is the authoritative
|
||||
input to `playlist-sequencing-data.py` and the single source of truth for
|
||||
sequencing decisions.
|
||||
|
||||
This script bootstraps that YAML for a band that doesn't yet have one. It
|
||||
runs in two modes:
|
||||
|
||||
--empty (default)
|
||||
Write a template with no tracks listed. The user fills in the order.
|
||||
|
||||
--from-songbook
|
||||
Scan `docs/songbook/{band-slug}/` for published songbook entries and
|
||||
pre-populate the tracks list with their titles. Audio file fields
|
||||
are left as TODO comments — the user must fill in actual filenames
|
||||
from `docs/audio/` because songbook frontmatter does not reliably
|
||||
track the audio filename.
|
||||
|
||||
Usage:
|
||||
scaffold-playlist.py <band-slug> [--from-songbook] [--project-root PATH]
|
||||
|
||||
Exit codes:
|
||||
0 = playlist YAML written (or already existed and --force not passed)
|
||||
1 = error (band-slug invalid, project root missing, etc.)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _band_name_from_slug(slug: str) -> str:
|
||||
"""Convert kebab-case slug to a Title-Cased album name as a default.
|
||||
Users typically edit this after scaffolding."""
|
||||
parts = slug.replace("_", "-").split("-")
|
||||
return " ".join(p.capitalize() for p in parts if p)
|
||||
|
||||
|
||||
def _extract_title_from_songbook(md_path: Path) -> str | None:
|
||||
"""Read a songbook .md file's frontmatter and return its `title` field.
|
||||
Returns None if the file lacks a frontmatter title."""
|
||||
try:
|
||||
with open(md_path, "r") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return None
|
||||
if not content.startswith("---"):
|
||||
return None
|
||||
end = content.find("\n---", 3)
|
||||
if end < 0:
|
||||
return None
|
||||
fm = content[3:end]
|
||||
for line in fm.splitlines():
|
||||
m = re.match(r'^\s*title\s*:\s*"?(.*?)"?\s*$', line)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _is_published(md_path: Path) -> bool:
|
||||
"""Heuristic: check frontmatter for `status: published` or similar."""
|
||||
try:
|
||||
with open(md_path, "r") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return False
|
||||
if not content.startswith("---"):
|
||||
return False
|
||||
end = content.find("\n---", 3)
|
||||
if end < 0:
|
||||
return False
|
||||
fm = content[3:end].lower()
|
||||
return "status: published" in fm or "status: \"published\"" in fm
|
||||
|
||||
|
||||
def discover_songbook_tracks(project_root: Path, band_slug: str) -> list[dict]:
|
||||
"""Find published songbook entries for the band and return their titles."""
|
||||
band_dir = project_root / "docs" / "songbook" / band_slug
|
||||
if not band_dir.is_dir():
|
||||
return []
|
||||
tracks = []
|
||||
for md_path in sorted(band_dir.glob("*.md")):
|
||||
if not _is_published(md_path):
|
||||
continue
|
||||
title = _extract_title_from_songbook(md_path)
|
||||
if title:
|
||||
tracks.append({"name": title, "songbook_path": str(md_path.relative_to(project_root))})
|
||||
return tracks
|
||||
|
||||
|
||||
def render_playlist_yaml(album_name: str, tracks: list[dict], from_songbook: bool) -> str:
|
||||
"""Render the playlist YAML content as a string."""
|
||||
lines = []
|
||||
lines.append(f"# Playlist order for {album_name} — authoritative source.")
|
||||
lines.append("# This file is the SINGLE source of truth for the band's track sequence.")
|
||||
lines.append("# Do NOT duplicate this list in other files (band profile YAML, ordering doc,")
|
||||
lines.append("# voice context). Those files derive from or reference this YAML.")
|
||||
lines.append("#")
|
||||
lines.append("# When a song is published, add it to this file in the same write batch as")
|
||||
lines.append("# the songbook entry. When the order changes, update this file first; the")
|
||||
lines.append("# sequencing script's per-album companion .md is auto-refreshed from this.")
|
||||
lines.append(f'album: "{album_name}"')
|
||||
lines.append("tracks:")
|
||||
if not tracks:
|
||||
lines.append(" # Add tracks below as they are published. Each track needs:")
|
||||
lines.append(' # - name: "<song title as it appears in the songbook>"')
|
||||
lines.append(' # file: "<exact filename in docs/audio/, e.g. My Song.mp3>"')
|
||||
lines.append(" # Order in this list = playlist order.")
|
||||
else:
|
||||
for t in tracks:
|
||||
lines.append(f' - name: "{t["name"]}"')
|
||||
if from_songbook:
|
||||
# We discovered the song from songbook but don't know the audio filename.
|
||||
# User must fill this in.
|
||||
lines.append(" file: \"\" # TODO: set to the actual filename in docs/audio/")
|
||||
if t.get("songbook_path"):
|
||||
lines.append(f" # songbook: {t['songbook_path']}")
|
||||
else:
|
||||
lines.append(' file: ""')
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scaffold a per-band playlist YAML at docs/{band-slug}-playlist.yaml.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"band_slug",
|
||||
help="The band's filename slug (kebab-case). Matches the band profile filename: docs/band-profiles/{band-slug}.yaml.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--from-songbook",
|
||||
action="store_true",
|
||||
help="Pre-populate tracks from existing songbook entries at docs/songbook/{band-slug}/.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
default=".",
|
||||
help="Project root (default: current directory).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--album-name",
|
||||
help="Album/band name to use in the YAML (default: derived from slug).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite existing playlist YAML if present.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
project_root = Path(args.project_root).resolve()
|
||||
if not project_root.is_dir():
|
||||
print(json.dumps({"status": "error", "message": f"Project root not found: {project_root}"}))
|
||||
sys.exit(1)
|
||||
|
||||
slug = args.band_slug.strip()
|
||||
if not re.match(r"^[a-z0-9][a-z0-9_-]*$", slug):
|
||||
print(json.dumps({
|
||||
"status": "error",
|
||||
"message": (
|
||||
f"Invalid band slug {slug!r}. Use lowercase kebab-case "
|
||||
f"(letters, digits, hyphens, underscores; must start with letter/digit)."
|
||||
),
|
||||
}))
|
||||
sys.exit(1)
|
||||
|
||||
target = project_root / "docs" / f"{slug}-playlist.yaml"
|
||||
if target.exists() and not args.force:
|
||||
print(json.dumps({
|
||||
"status": "exists",
|
||||
"message": f"Playlist YAML already exists at {target}. Use --force to overwrite.",
|
||||
"path": str(target.relative_to(project_root)),
|
||||
}))
|
||||
sys.exit(0)
|
||||
|
||||
album_name = args.album_name or _band_name_from_slug(slug)
|
||||
tracks: list[dict] = []
|
||||
if args.from_songbook:
|
||||
tracks = discover_songbook_tracks(project_root, slug)
|
||||
|
||||
body = render_playlist_yaml(album_name, tracks, from_songbook=args.from_songbook)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(target, "w") as f:
|
||||
f.write(body)
|
||||
|
||||
print(json.dumps({
|
||||
"status": "created" if not args.force else "overwritten",
|
||||
"path": str(target.relative_to(project_root)),
|
||||
"album": album_name,
|
||||
"tracks_seeded": len(tracks),
|
||||
"from_songbook": args.from_songbook,
|
||||
"note": (
|
||||
"Audio filenames left as empty strings — fill in from docs/audio/ before "
|
||||
"running the sequencing script."
|
||||
) if tracks else (
|
||||
"Empty template written. Add tracks as you publish them."
|
||||
),
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0", "pyyaml>=6.0"]
|
||||
# ///
|
||||
"""Tests for diff-profiles.py"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
spec = spec_from_file_location(
|
||||
"diff_profiles",
|
||||
Path(__file__).parent.parent / "diff-profiles.py"
|
||||
)
|
||||
diff_profiles_mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(diff_profiles_mod)
|
||||
diff_profiles = diff_profiles_mod.diff_profiles
|
||||
|
||||
|
||||
PROFILE_A = {
|
||||
"name": "Test Band",
|
||||
"genre": "indie rock",
|
||||
"mood": "melancholic",
|
||||
"model_preference": "v4.5-all",
|
||||
"tier": "free",
|
||||
"style_baseline": "Indie rock with warm guitars",
|
||||
"vocal": {
|
||||
"gender": "male",
|
||||
"tone": "warm",
|
||||
"delivery": "intimate",
|
||||
"energy": "restrained",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_yaml(tmp_path, filename, data):
|
||||
path = tmp_path / filename
|
||||
with open(path, "w") as f:
|
||||
yaml.dump(data, f)
|
||||
return path
|
||||
|
||||
|
||||
def test_identical_profiles(tmp_path):
|
||||
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
|
||||
b = write_yaml(tmp_path, "b.yaml", PROFILE_A)
|
||||
result = diff_profiles(a, b)
|
||||
assert result["status"] == "pass"
|
||||
assert result["has_changes"] is False
|
||||
assert result["summary"]["total_changes"] == 0
|
||||
|
||||
|
||||
def test_changed_fields(tmp_path):
|
||||
modified = {**PROFILE_A, "genre": "electronic", "mood": "energetic"}
|
||||
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
|
||||
b = write_yaml(tmp_path, "b.yaml", modified)
|
||||
result = diff_profiles(a, b)
|
||||
assert result["has_changes"] is True
|
||||
assert result["summary"]["fields_changed"] == 2
|
||||
changed_fields = [c["field"] for c in result["changed"]]
|
||||
assert "genre" in changed_fields
|
||||
assert "mood" in changed_fields
|
||||
|
||||
|
||||
def test_added_fields(tmp_path):
|
||||
modified = {**PROFILE_A, "language": "Spanish", "instrumental": True}
|
||||
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
|
||||
b = write_yaml(tmp_path, "b.yaml", modified)
|
||||
result = diff_profiles(a, b)
|
||||
assert result["has_changes"] is True
|
||||
assert result["summary"]["fields_added"] >= 2
|
||||
added_fields = [c["field"] for c in result["added"]]
|
||||
assert "language" in added_fields
|
||||
assert "instrumental" in added_fields
|
||||
|
||||
|
||||
def test_removed_fields(tmp_path):
|
||||
modified = {k: v for k, v in PROFILE_A.items() if k != "mood"}
|
||||
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
|
||||
b = write_yaml(tmp_path, "b.yaml", modified)
|
||||
result = diff_profiles(a, b)
|
||||
assert result["has_changes"] is True
|
||||
assert result["summary"]["fields_removed"] >= 1
|
||||
removed_fields = [c["field"] for c in result["removed"]]
|
||||
assert "mood" in removed_fields
|
||||
|
||||
|
||||
def test_nested_changes(tmp_path):
|
||||
modified = {**PROFILE_A, "vocal": {**PROFILE_A["vocal"], "tone": "bright, clear"}}
|
||||
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
|
||||
b = write_yaml(tmp_path, "b.yaml", modified)
|
||||
result = diff_profiles(a, b)
|
||||
assert result["has_changes"] is True
|
||||
changed_fields = [c["field"] for c in result["changed"]]
|
||||
assert "vocal.tone" in changed_fields
|
||||
|
||||
|
||||
def test_missing_original(tmp_path):
|
||||
b = write_yaml(tmp_path, "b.yaml", PROFILE_A)
|
||||
result = diff_profiles(tmp_path / "nope.yaml", b)
|
||||
assert result["status"] == "fail"
|
||||
assert "errors" in result
|
||||
|
||||
|
||||
def test_missing_modified(tmp_path):
|
||||
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
|
||||
result = diff_profiles(a, tmp_path / "nope.yaml")
|
||||
assert result["status"] == "fail"
|
||||
|
||||
|
||||
def test_invalid_yaml(tmp_path):
|
||||
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
|
||||
bad = tmp_path / "bad.yaml"
|
||||
bad.write_text(": {{invalid yaml")
|
||||
result = diff_profiles(a, bad)
|
||||
assert result["status"] == "fail"
|
||||
|
||||
|
||||
def test_mixed_changes(tmp_path):
|
||||
modified = {**PROFILE_A, "genre": "electronic", "language": "French"}
|
||||
del modified["mood"]
|
||||
a = write_yaml(tmp_path, "a.yaml", PROFILE_A)
|
||||
b = write_yaml(tmp_path, "b.yaml", modified)
|
||||
result = diff_profiles(a, b)
|
||||
assert result["has_changes"] is True
|
||||
assert result["summary"]["fields_changed"] >= 1
|
||||
assert result["summary"]["fields_added"] >= 1
|
||||
assert result["summary"]["fields_removed"] >= 1
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0", "pyyaml>=6.0"]
|
||||
# ///
|
||||
"""Tests for list-profiles.py"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
spec = spec_from_file_location(
|
||||
"list_profiles",
|
||||
Path(__file__).parent.parent / "list-profiles.py"
|
||||
)
|
||||
list_profiles_mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(list_profiles_mod)
|
||||
list_profiles = list_profiles_mod.list_profiles
|
||||
check_profile = list_profiles_mod.check_profile
|
||||
|
||||
|
||||
SAMPLE_PROFILE = {
|
||||
"name": "Test Band",
|
||||
"genre": "indie rock",
|
||||
"mood": "melancholic",
|
||||
"model_preference": "v4.5-all",
|
||||
"tier": "free",
|
||||
}
|
||||
|
||||
|
||||
def test_nonexistent_directory(tmp_path):
|
||||
result = list_profiles(tmp_path / "nope")
|
||||
assert result["status"] == "pass"
|
||||
assert result["count"] == 0
|
||||
assert "No profiles directory" in result.get("message", "")
|
||||
|
||||
|
||||
def test_empty_directory(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
result = list_profiles(profiles_dir)
|
||||
assert result["status"] == "pass"
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
def test_single_profile(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
with open(profiles_dir / "test-band.yaml", "w") as f:
|
||||
yaml.dump(SAMPLE_PROFILE, f)
|
||||
result = list_profiles(profiles_dir)
|
||||
assert result["count"] == 1
|
||||
assert result["profiles"][0]["name"] == "Test Band"
|
||||
assert result["profiles"][0]["genre"] == "indie rock"
|
||||
|
||||
|
||||
def test_multiple_profiles(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
for i in range(3):
|
||||
data = {**SAMPLE_PROFILE, "name": f"Band {i}"}
|
||||
with open(profiles_dir / f"band-{i}.yaml", "w") as f:
|
||||
yaml.dump(data, f)
|
||||
result = list_profiles(profiles_dir)
|
||||
assert result["count"] == 3
|
||||
|
||||
|
||||
def test_writer_voice_detection(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
data_with_voice = {
|
||||
**SAMPLE_PROFILE,
|
||||
"writer_voice": {"vocabulary": "formal, archaic", "rhythm": "long flowing"}
|
||||
}
|
||||
with open(profiles_dir / "voiced.yaml", "w") as f:
|
||||
yaml.dump(data_with_voice, f)
|
||||
data_without = {**SAMPLE_PROFILE}
|
||||
with open(profiles_dir / "plain.yaml", "w") as f:
|
||||
yaml.dump(data_without, f)
|
||||
|
||||
result = list_profiles(profiles_dir)
|
||||
voiced = next(p for p in result["profiles"] if p["file"] == "voiced.yaml")
|
||||
plain = next(p for p in result["profiles"] if p["file"] == "plain.yaml")
|
||||
assert voiced["has_writer_voice"] is True
|
||||
assert plain["has_writer_voice"] is False
|
||||
|
||||
|
||||
def test_invalid_yaml_skipped(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
(profiles_dir / "bad.yaml").write_text(": {{invalid")
|
||||
with open(profiles_dir / "good.yaml", "w") as f:
|
||||
yaml.dump(SAMPLE_PROFILE, f)
|
||||
result = list_profiles(profiles_dir)
|
||||
assert result["count"] == 1
|
||||
|
||||
|
||||
def test_new_fields_in_listing(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
data = {
|
||||
**SAMPLE_PROFILE,
|
||||
"instrumental": True,
|
||||
"language": "Spanish",
|
||||
"creativity_default": "experimental",
|
||||
"generation_history": [{"date": "2026-03-19"}],
|
||||
}
|
||||
with open(profiles_dir / "test.yaml", "w") as f:
|
||||
yaml.dump(data, f)
|
||||
result = list_profiles(profiles_dir)
|
||||
p = result["profiles"][0]
|
||||
assert p["instrumental"] is True
|
||||
assert p["language"] == "Spanish"
|
||||
assert p["creativity_default"] == "experimental"
|
||||
assert p["has_generation_history"] is True
|
||||
|
||||
|
||||
# --- check_profile tests ---
|
||||
|
||||
def test_check_profile_exists(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
with open(profiles_dir / "test-band.yaml", "w") as f:
|
||||
yaml.dump(SAMPLE_PROFILE, f)
|
||||
result = check_profile(profiles_dir, "test-band")
|
||||
assert result["exists"] is True
|
||||
assert result["name"] == "Test Band"
|
||||
assert "size_bytes" in result
|
||||
assert "last_modified" in result
|
||||
|
||||
|
||||
def test_check_profile_exists_with_extension(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
with open(profiles_dir / "test-band.yaml", "w") as f:
|
||||
yaml.dump(SAMPLE_PROFILE, f)
|
||||
result = check_profile(profiles_dir, "test-band.yaml")
|
||||
assert result["exists"] is True
|
||||
|
||||
|
||||
def test_check_profile_not_exists(tmp_path):
|
||||
profiles_dir = tmp_path / "profiles"
|
||||
profiles_dir.mkdir()
|
||||
result = check_profile(profiles_dir, "nonexistent")
|
||||
assert result["exists"] is False
|
||||
assert result["query"] == "nonexistent"
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0"]
|
||||
# ///
|
||||
"""Tests for tier-features.py"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
spec = spec_from_file_location(
|
||||
"tier_features",
|
||||
Path(__file__).parent.parent / "tier-features.py"
|
||||
)
|
||||
tier_features_mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(tier_features_mod)
|
||||
get_tier_features = tier_features_mod.get_tier_features
|
||||
|
||||
|
||||
def test_free_tier():
|
||||
result = get_tier_features("free")
|
||||
assert result["status"] == "pass"
|
||||
assert result["tier"] == "free"
|
||||
assert result["sliders_available"] is False
|
||||
assert result["personas_available"] is False
|
||||
assert result["audio_influence_available"] is False
|
||||
assert result["studio_available"] is False
|
||||
assert "v4.5-all" in result["models"]
|
||||
assert len(result["models"]) == 1
|
||||
|
||||
|
||||
def test_pro_tier():
|
||||
result = get_tier_features("pro")
|
||||
assert result["status"] == "pass"
|
||||
assert result["sliders_available"] is True
|
||||
assert result["personas_available"] is True
|
||||
assert result["audio_influence_available"] is True
|
||||
assert result["studio_available"] is False
|
||||
assert "v5 Pro" in result["models"]
|
||||
assert len(result["unavailable"]) >= 1 # Studio and related
|
||||
|
||||
|
||||
def test_premier_tier():
|
||||
result = get_tier_features("premier")
|
||||
assert result["status"] == "pass"
|
||||
assert result["sliders_available"] is True
|
||||
assert result["studio_available"] is True
|
||||
assert len(result["unavailable"]) == 0 # Everything available
|
||||
|
||||
|
||||
def test_invalid_tier():
|
||||
result = get_tier_features("ultimate")
|
||||
assert result["status"] == "fail"
|
||||
assert "error" in result
|
||||
|
||||
|
||||
def test_case_insensitive():
|
||||
result = get_tier_features("PRO")
|
||||
assert result["status"] == "pass"
|
||||
assert result["tier"] == "pro"
|
||||
|
||||
|
||||
def test_free_has_unavailable_features():
|
||||
result = get_tier_features("free")
|
||||
assert len(result["unavailable"]) > 5 # Many features gated
|
||||
|
||||
|
||||
def test_all_tiers_have_available():
|
||||
for tier in ["free", "pro", "premier"]:
|
||||
result = get_tier_features(tier)
|
||||
assert len(result["available"]) > 0
|
||||
|
||||
|
||||
def test_all_tiers_have_pricing():
|
||||
for tier in ["free", "pro", "premier"]:
|
||||
result = get_tier_features(tier)
|
||||
assert "pricing" in result
|
||||
assert "monthly" in result["pricing"]
|
||||
assert "annual_monthly" in result["pricing"]
|
||||
|
||||
|
||||
def test_all_tiers_have_song_length():
|
||||
for tier in ["free", "pro", "premier"]:
|
||||
result = get_tier_features(tier)
|
||||
assert "song_length_max" in result
|
||||
|
||||
|
||||
def test_all_tiers_have_download_quality():
|
||||
for tier in ["free", "pro", "premier"]:
|
||||
result = get_tier_features(tier)
|
||||
assert "download_quality" in result
|
||||
|
||||
|
||||
def test_all_tiers_have_credit_cost():
|
||||
for tier in ["free", "pro", "premier"]:
|
||||
result = get_tier_features(tier)
|
||||
assert "credit_cost" in result
|
||||
assert result["credit_cost"]["generation"] == 10
|
||||
assert result["credit_cost"]["extension"] == 5
|
||||
|
||||
|
||||
def test_free_pricing_is_zero():
|
||||
result = get_tier_features("free")
|
||||
assert result["pricing"]["monthly"] == 0
|
||||
assert result["pricing"]["annual_monthly"] == 0
|
||||
|
||||
|
||||
def test_pro_pricing():
|
||||
result = get_tier_features("pro")
|
||||
assert result["pricing"]["monthly"] == 10
|
||||
assert result["pricing"]["annual_monthly"] == 8
|
||||
|
||||
|
||||
def test_premier_pricing():
|
||||
result = get_tier_features("premier")
|
||||
assert result["pricing"]["monthly"] == 30
|
||||
assert result["pricing"]["annual_monthly"] == 24
|
||||
|
||||
|
||||
def test_legacy_models_flagged():
|
||||
for tier in ["pro", "premier"]:
|
||||
result = get_tier_features(tier)
|
||||
assert "legacy_models" in result
|
||||
assert "v4 Pro" in result["legacy_models"]
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pytest>=7.0", "pyyaml>=6.0"]
|
||||
# ///
|
||||
"""Tests for validate-profile.py"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
# Add parent directory to path for import
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
# Import the module
|
||||
spec = spec_from_file_location(
|
||||
"validate_profile",
|
||||
Path(__file__).parent.parent / "validate-profile.py"
|
||||
)
|
||||
validate_profile_mod = module_from_spec(spec)
|
||||
spec.loader.exec_module(validate_profile_mod)
|
||||
validate_profile = validate_profile_mod.validate_profile
|
||||
derive_filename = validate_profile_mod.derive_filename
|
||||
|
||||
|
||||
def write_profile(tmp_path, data):
|
||||
"""Helper to write a YAML profile and return its path."""
|
||||
profile_path = tmp_path / "test-band.yaml"
|
||||
with open(profile_path, "w") as f:
|
||||
yaml.dump(data, f)
|
||||
return profile_path
|
||||
|
||||
|
||||
VALID_PROFILE = {
|
||||
"name": "Test Band",
|
||||
"genre": "indie rock",
|
||||
"mood": "melancholic",
|
||||
"model_preference": "v4.5-all",
|
||||
"tier": "free",
|
||||
"style_baseline": "Indie rock with warm guitars and atmospheric pads",
|
||||
"vocal": {
|
||||
"gender": "male",
|
||||
"tone": "warm, breathy",
|
||||
"delivery": "intimate",
|
||||
"energy": "restrained",
|
||||
},
|
||||
}
|
||||
|
||||
VALID_INSTRUMENTAL_PROFILE = {
|
||||
"name": "Ambient Waves",
|
||||
"genre": "ambient electronic",
|
||||
"mood": "contemplative, spacious",
|
||||
"model_preference": "v4.5-all",
|
||||
"tier": "free",
|
||||
"style_baseline": "Ambient electronic with lush pads and field recordings",
|
||||
"instrumental": True,
|
||||
}
|
||||
|
||||
|
||||
def test_valid_profile(tmp_path):
|
||||
path = write_profile(tmp_path, VALID_PROFILE)
|
||||
result = validate_profile(path)
|
||||
assert result["status"] == "pass"
|
||||
assert result["summary"]["total"] == 0
|
||||
|
||||
|
||||
def test_missing_file(tmp_path):
|
||||
path = tmp_path / "nonexistent.yaml"
|
||||
result = validate_profile(path)
|
||||
assert result["status"] == "fail"
|
||||
assert result["summary"]["critical"] == 1
|
||||
|
||||
|
||||
def test_invalid_yaml(tmp_path):
|
||||
path = tmp_path / "bad.yaml"
|
||||
path.write_text(": invalid: yaml: {{{{")
|
||||
result = validate_profile(path)
|
||||
assert result["status"] == "fail"
|
||||
assert result["summary"]["critical"] >= 1
|
||||
|
||||
|
||||
def test_missing_required_fields(tmp_path):
|
||||
path = write_profile(tmp_path, {"name": "Test"})
|
||||
result = validate_profile(path)
|
||||
assert result["status"] == "fail"
|
||||
assert result["summary"]["critical"] >= 1
|
||||
|
||||
|
||||
def test_invalid_model(tmp_path):
|
||||
data = {**VALID_PROFILE, "model_preference": "v99 Ultra"}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any(f.get("location", {}).get("field") == "model_preference"
|
||||
for f in result["findings"])
|
||||
|
||||
|
||||
def test_invalid_tier(tmp_path):
|
||||
data = {**VALID_PROFILE, "tier": "ultimate"}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("tier" in str(f) for f in result["findings"])
|
||||
|
||||
|
||||
def test_style_baseline_too_long(tmp_path):
|
||||
data = {**VALID_PROFILE, "style_baseline": "x" * 1001}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("style_baseline" in str(f) for f in result["findings"])
|
||||
|
||||
|
||||
def test_style_baseline_v4_pro_200_limit(tmp_path):
|
||||
data = {**VALID_PROFILE, "model_preference": "v4 Pro", "tier": "pro",
|
||||
"style_baseline": "x" * 201}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("style_baseline" in str(f) and "200" in str(f)
|
||||
for f in result["findings"])
|
||||
|
||||
|
||||
def test_free_tier_wrong_model(tmp_path):
|
||||
data = {**VALID_PROFILE, "tier": "free", "model_preference": "v5 Pro"}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("free" in f.get("issue", "").lower() or "free" in f.get("fix", "").lower()
|
||||
for f in result["findings"])
|
||||
|
||||
|
||||
def test_free_tier_slider_warning(tmp_path):
|
||||
data = {**VALID_PROFILE, "sliders": {"weirdness": 80, "style_influence": 30}}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("slider" in f.get("issue", "").lower() for f in result["findings"])
|
||||
|
||||
|
||||
def test_slider_out_of_range(tmp_path):
|
||||
data = {**VALID_PROFILE, "tier": "pro", "model_preference": "v5 Pro",
|
||||
"sliders": {"weirdness": 150}}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("out of range" in f.get("issue", "").lower() for f in result["findings"])
|
||||
|
||||
|
||||
def test_audio_influence_slider_validation(tmp_path):
|
||||
data = {**VALID_PROFILE, "tier": "pro", "model_preference": "v5 Pro",
|
||||
"sliders": {"audio_influence": 200}}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("audio_influence" in str(f) and "out of range" in f.get("issue", "").lower()
|
||||
for f in result["findings"])
|
||||
|
||||
|
||||
def test_invalid_vocal_gender(tmp_path):
|
||||
data = {**VALID_PROFILE}
|
||||
data["vocal"] = {**VALID_PROFILE["vocal"], "gender": "robot"}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("gender" in str(f) for f in result["findings"])
|
||||
|
||||
|
||||
def test_missing_vocal_fields(tmp_path):
|
||||
data = {**VALID_PROFILE, "vocal": {"gender": "male"}}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert result["summary"]["high"] >= 1
|
||||
|
||||
|
||||
def test_too_many_exclusions(tmp_path):
|
||||
data = {**VALID_PROFILE, "exclusion_defaults": [f"no thing {i}" for i in range(7)]}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("exclusion" in f.get("issue", "").lower() for f in result["findings"])
|
||||
|
||||
|
||||
def test_pro_tier_valid_with_sliders(tmp_path):
|
||||
data = {
|
||||
**VALID_PROFILE,
|
||||
"tier": "pro",
|
||||
"model_preference": "v5 Pro",
|
||||
"sliders": {"weirdness": 70, "style_influence": 40},
|
||||
}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert result["status"] == "pass"
|
||||
|
||||
|
||||
# --- Instrumental profile tests ---
|
||||
|
||||
def test_instrumental_profile_valid_without_vocal(tmp_path):
|
||||
path = write_profile(tmp_path, VALID_INSTRUMENTAL_PROFILE)
|
||||
result = validate_profile(path)
|
||||
assert result["status"] == "pass"
|
||||
assert result["summary"]["total"] == 0
|
||||
|
||||
|
||||
def test_instrumental_profile_with_optional_vocal(tmp_path):
|
||||
data = {**VALID_INSTRUMENTAL_PROFILE, "vocal": {"gender": "any"}}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert result["status"] == "pass"
|
||||
|
||||
|
||||
def test_non_instrumental_requires_vocal(tmp_path):
|
||||
data = {**VALID_PROFILE}
|
||||
del data["vocal"]
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert result["status"] == "fail"
|
||||
assert result["summary"]["high"] >= 1
|
||||
|
||||
|
||||
# --- New field tests ---
|
||||
|
||||
def test_valid_creativity_default(tmp_path):
|
||||
for mode in ["conservative", "balanced", "experimental"]:
|
||||
data = {**VALID_PROFILE, "creativity_default": mode}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert not any(f.get("location", {}).get("field") == "creativity_default"
|
||||
for f in result["findings"]), f"Failed for {mode}"
|
||||
|
||||
|
||||
def test_invalid_creativity_default(tmp_path):
|
||||
data = {**VALID_PROFILE, "creativity_default": "wild"}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("creativity_default" in str(f) for f in result["findings"])
|
||||
|
||||
|
||||
def test_valid_language(tmp_path):
|
||||
data = {**VALID_PROFILE, "language": "Spanish"}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert not any(f.get("location", {}).get("field") == "language"
|
||||
for f in result["findings"])
|
||||
|
||||
|
||||
def test_empty_language(tmp_path):
|
||||
data = {**VALID_PROFILE, "language": ""}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("language" in str(f) for f in result["findings"])
|
||||
|
||||
|
||||
def test_generation_history_valid(tmp_path):
|
||||
data = {**VALID_PROFILE, "generation_history": [
|
||||
{"date": "2026-03-19", "style_prompt": "test", "model": "v4.5-all"}
|
||||
]}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert not any(f.get("location", {}).get("field") == "generation_history"
|
||||
for f in result["findings"])
|
||||
|
||||
|
||||
def test_generation_history_too_many(tmp_path):
|
||||
data = {**VALID_PROFILE, "generation_history": [
|
||||
{"date": f"2026-03-{i:02d}"} for i in range(1, 15)
|
||||
]}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("generation_history" in str(f) for f in result["findings"])
|
||||
|
||||
|
||||
def test_generation_history_not_list(tmp_path):
|
||||
data = {**VALID_PROFILE, "generation_history": "not a list"}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("generation_history" in str(f) for f in result["findings"])
|
||||
|
||||
|
||||
def test_studio_preferences_non_premier_warning(tmp_path):
|
||||
data = {**VALID_PROFILE, "tier": "pro", "model_preference": "v5 Pro",
|
||||
"studio_preferences": {"bpm": 120, "key": "C minor"}}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("studio" in f.get("issue", "").lower() for f in result["findings"])
|
||||
|
||||
|
||||
def test_studio_preferences_premier_valid(tmp_path):
|
||||
data = {**VALID_PROFILE, "tier": "premier", "model_preference": "v5 Pro",
|
||||
"studio_preferences": {"bpm": 120, "key": "C minor", "time_signature": "4/4"}}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert not any("studio" in f.get("issue", "").lower() for f in result["findings"])
|
||||
|
||||
|
||||
def test_studio_preferences_invalid_bpm(tmp_path):
|
||||
data = {**VALID_PROFILE, "tier": "premier", "model_preference": "v5 Pro",
|
||||
"studio_preferences": {"bpm": "fast"}}
|
||||
path = write_profile(tmp_path, data)
|
||||
result = validate_profile(path)
|
||||
assert any("bpm" in str(f).lower() for f in result["findings"])
|
||||
|
||||
|
||||
# --- derive_filename tests ---
|
||||
|
||||
def test_derive_filename_basic():
|
||||
assert derive_filename("Test Band") == "test-band.yaml"
|
||||
|
||||
|
||||
def test_derive_filename_special_chars():
|
||||
assert derive_filename("The Band's Name!") == "the-bands-name.yaml"
|
||||
|
||||
|
||||
def test_derive_filename_multiple_spaces():
|
||||
assert derive_filename(" My Cool Band ") == "my-cool-band.yaml"
|
||||
|
||||
|
||||
def test_derive_filename_already_kebab():
|
||||
assert derive_filename("already-kebab") == "already-kebab.yaml"
|
||||
223
.agent/skills/suno-band-profile-manager/scripts/tier-features.py
Normal file
223
.agent/skills/suno-band-profile-manager/scripts/tier-features.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
"""Return Suno feature availability for a given subscription tier.
|
||||
|
||||
Maps each tier (free, pro, premier) to its available and unavailable features,
|
||||
helping the agent and user understand what profile options are valid.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
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 VALID_TIERS
|
||||
|
||||
|
||||
TIER_FEATURES = {
|
||||
"free": {
|
||||
"available": [
|
||||
"v4.5-all model",
|
||||
"50 credits/day (~10 songs)",
|
||||
"Vocal Gender selection",
|
||||
"Manual/Auto Lyrics mode",
|
||||
"Song Title",
|
||||
"1 min audio upload",
|
||||
"Song length determined by model — v4.5-all supports up to ~8 min",
|
||||
"128kbps MP3 download",
|
||||
],
|
||||
"unavailable": [
|
||||
"v5 Pro and other paid models",
|
||||
"Commercial use",
|
||||
"Personas (consistent voice reuse)",
|
||||
"Weirdness slider (0-100)",
|
||||
"Style Influence slider (0-100)",
|
||||
"Audio Influence slider (0-100)",
|
||||
"Add Vocals / Add Instrumental",
|
||||
"Stems separation",
|
||||
"Advanced editing",
|
||||
"Studio features",
|
||||
"Studio 1.2 (Warp Markers, Remove FX, Alternates, Time Signature)",
|
||||
"MIDI export",
|
||||
"Priority queue",
|
||||
"Add-on credits",
|
||||
"320kbps MP3 / WAV download",
|
||||
],
|
||||
"models": ["v4.5-all"],
|
||||
"legacy_models": [],
|
||||
"sliders_available": False,
|
||||
"personas_available": False,
|
||||
"voices_available": False,
|
||||
"custom_models_available": False,
|
||||
"audio_influence_available": False,
|
||||
"legacy_editor_available": False,
|
||||
"studio_available": False,
|
||||
"song_length_max": "Determined by model — v4.5-all supports up to ~8 min",
|
||||
"download_quality": "128kbps MP3",
|
||||
"credit_cost": {"generation": 10, "extension": 5},
|
||||
"pricing": {"monthly": 0, "annual_monthly": 0},
|
||||
},
|
||||
"pro": {
|
||||
"available": [
|
||||
"All models including v5 Pro and v5.5 Pro",
|
||||
"2,500 credits/month (~500 songs)",
|
||||
"Commercial use (new songs)",
|
||||
"Personas (v4.5/v5), Voices (v5.5)",
|
||||
"Weirdness slider (0-100)",
|
||||
"Style Influence slider (0-100)",
|
||||
"Audio Influence slider (0-100, with audio upload)",
|
||||
"Custom Models (up to 3, v5.5)",
|
||||
"Add Vocals / Add Instrumental (beta)",
|
||||
"Covers (beta)",
|
||||
"Remaster (Subtle/Normal/High)",
|
||||
"Up to 12 stems",
|
||||
"8 min audio upload",
|
||||
"Legacy Editor (Replace, Extend, Crop, Fade, Rearrange)",
|
||||
"Priority queue (10 concurrent)",
|
||||
"Add-on credits",
|
||||
"Song length determined by model — v4.5/v5 support up to ~8 min",
|
||||
"320kbps MP3 + WAV download",
|
||||
],
|
||||
"unavailable": [
|
||||
"Suno Studio (full GAW)",
|
||||
"Warp Markers",
|
||||
"Remove FX",
|
||||
"Alternates / Take Lanes",
|
||||
"EQ (6-band per track)",
|
||||
"Time Signature control",
|
||||
"Context Window",
|
||||
"Recording (microphone)",
|
||||
"Loop Recording",
|
||||
"Sounds Mode (text-to-sound)",
|
||||
"Stem Cover",
|
||||
"Heal Edits",
|
||||
"MIDI export (10 credits/stem)",
|
||||
"MILO-1080 Sequencer",
|
||||
],
|
||||
"models": ["v4.5-all", "v4 Pro", "v4.5 Pro", "v4.5+ Pro", "v5 Pro", "v5.5 Pro"],
|
||||
"legacy_models": ["v4 Pro"],
|
||||
"sliders_available": True,
|
||||
"personas_available": True,
|
||||
"voices_available": True,
|
||||
"custom_models_available": True,
|
||||
"audio_influence_available": True,
|
||||
"studio_available": False,
|
||||
"legacy_editor_available": True,
|
||||
"song_length_max": "Determined by model — v4.5/v5/v5.5 support up to ~8 min",
|
||||
"download_quality": "320kbps MP3 + WAV",
|
||||
"credit_cost": {"generation": 10, "extension": 5},
|
||||
"pricing": {"monthly": 10, "annual_monthly": 8},
|
||||
},
|
||||
"premier": {
|
||||
"available": [
|
||||
"All models including v5 Pro, v5.5 Pro + Studio",
|
||||
"10,000 credits/month (~2,000 songs)",
|
||||
"Commercial use (new songs)",
|
||||
"Personas (v4.5/v5), Voices (v5.5)",
|
||||
"Weirdness slider (0-100)",
|
||||
"Style Influence slider (0-100)",
|
||||
"Audio Influence slider (0-100, with audio upload)",
|
||||
"Custom Models (up to 3, v5.5)",
|
||||
"Add Vocals / Add Instrumental (beta)",
|
||||
"Covers (beta)",
|
||||
"Remaster (Subtle/Normal/High)",
|
||||
"Up to 12 stems",
|
||||
"8 min audio upload",
|
||||
"Legacy Editor (Replace, Extend, Crop, Fade, Rearrange)",
|
||||
"Suno Studio (full GAW)",
|
||||
"Warp Markers",
|
||||
"Remove FX",
|
||||
"Alternates / Take Lanes",
|
||||
"EQ (6-band per track)",
|
||||
"Time Signature control (editing only — not sent to generative models)",
|
||||
"Context Window",
|
||||
"Recording (microphone)",
|
||||
"Loop Recording",
|
||||
"Sounds Mode (text-to-sound, beta)",
|
||||
"Stem Cover",
|
||||
"Heal Edits",
|
||||
"MIDI export (10 credits/stem)",
|
||||
"MILO-1080 Sequencer",
|
||||
"Priority queue (10 concurrent)",
|
||||
"Add-on credits",
|
||||
"Song length determined by model — v4.5/v5 support up to ~8 min",
|
||||
"320kbps MP3 + WAV download",
|
||||
],
|
||||
"unavailable": [],
|
||||
"models": ["v4.5-all", "v4 Pro", "v4.5 Pro", "v4.5+ Pro", "v5 Pro", "v5.5 Pro"],
|
||||
"legacy_models": ["v4 Pro"],
|
||||
"sliders_available": True,
|
||||
"personas_available": True,
|
||||
"voices_available": True,
|
||||
"custom_models_available": True,
|
||||
"audio_influence_available": True,
|
||||
"studio_available": True,
|
||||
"legacy_editor_available": True,
|
||||
"song_length_max": "Determined by model — v4.5/v5/v5.5 support up to ~8 min",
|
||||
"download_quality": "320kbps MP3 + WAV",
|
||||
"credit_cost": {"generation": 10, "extension": 5},
|
||||
"pricing": {"monthly": 30, "annual_monthly": 24},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_tier_features(tier: str) -> dict:
|
||||
"""Return feature availability for the given tier."""
|
||||
script_name = "tier-features"
|
||||
tier_lower = tier.lower().strip()
|
||||
|
||||
if tier_lower not in VALID_TIERS:
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"error": f"Unknown tier '{tier}'. Must be one of: {', '.join(sorted(VALID_TIERS))}",
|
||||
}
|
||||
|
||||
features = TIER_FEATURES[tier_lower]
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "pass",
|
||||
"tier": tier_lower,
|
||||
**features,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Return available/unavailable Suno features for a given subscription tier.",
|
||||
epilog="Exit codes: 0=success, 1=invalid tier"
|
||||
)
|
||||
parser.add_argument("tier", choices=["free", "pro", "premier"], help="Suno subscription tier")
|
||||
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
print(f"Getting features for tier: {args.tier}", file=sys.stderr)
|
||||
|
||||
result = get_tier_features(args.tier)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
from pathlib import Path
|
||||
Path(args.output).write_text(output)
|
||||
if args.verbose:
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
sys.exit(0 if result["status"] == "pass" else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["pyyaml>=6.0"]
|
||||
# ///
|
||||
|
||||
"""Validate a band profile YAML file against the expected schema.
|
||||
|
||||
Checks required fields, value constraints, tier/model consistency,
|
||||
instrumental mode, style_baseline length, and new fields (language,
|
||||
creativity_default, generation_history, studio_preferences).
|
||||
Returns structured JSON findings.
|
||||
|
||||
Also supports --derive-filename to convert a band name to kebab-case filename.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
|
||||
from suno_constants import VALID_MODELS, VALID_TIERS, STYLE_PROMPT_LIMITS, STYLE_PROMPT_DEFAULT_MAX, FREE_TIER_MODEL
|
||||
|
||||
VALID_GENDERS = {"male", "female", "nonbinary", "any"}
|
||||
VALID_CREATIVITY = {"conservative", "balanced", "experimental"}
|
||||
STYLE_BASELINE_MAX = STYLE_PROMPT_DEFAULT_MAX
|
||||
STYLE_BASELINE_MAX_V4 = STYLE_PROMPT_LIMITS["v4 Pro"]
|
||||
MAX_GENERATION_HISTORY = 10
|
||||
|
||||
|
||||
def derive_filename(band_name: str) -> str:
|
||||
"""Convert a band name to kebab-case filename."""
|
||||
name = band_name.strip().lower()
|
||||
name = re.sub(r"[^a-z0-9\s-]", "", name)
|
||||
name = re.sub(r"[\s_]+", "-", name)
|
||||
name = re.sub(r"-+", "-", name)
|
||||
name = name.strip("-")
|
||||
return f"{name}.yaml"
|
||||
|
||||
|
||||
def validate_profile(profile_path: Path) -> dict:
|
||||
"""Validate a profile YAML file and return structured findings."""
|
||||
findings = []
|
||||
script_name = "validate-profile"
|
||||
|
||||
if not profile_path.exists():
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"skill_path": str(profile_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path)},
|
||||
"issue": "Profile file does not exist",
|
||||
"fix": f"Create the profile at {profile_path}"
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(profile_path) as f:
|
||||
profile = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"skill_path": str(profile_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path)},
|
||||
"issue": f"Invalid YAML: {e}",
|
||||
"fix": "Fix YAML syntax errors"
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
|
||||
}
|
||||
|
||||
if not isinstance(profile, dict):
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.0.0",
|
||||
"skill_path": str(profile_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "fail",
|
||||
"findings": [{
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path)},
|
||||
"issue": "Profile is not a YAML mapping",
|
||||
"fix": "Profile must be a YAML dictionary/mapping at the top level"
|
||||
}],
|
||||
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0}
|
||||
}
|
||||
|
||||
is_instrumental = profile.get("instrumental", False) is True
|
||||
|
||||
# Required top-level string fields
|
||||
for field in ["name", "genre", "mood", "model_preference", "tier", "style_baseline"]:
|
||||
val = profile.get(field)
|
||||
if not val or not isinstance(val, str) or not val.strip():
|
||||
findings.append({
|
||||
"severity": "critical",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "field": field},
|
||||
"issue": f"Required field '{field}' is missing or empty",
|
||||
"fix": f"Add a non-empty '{field}' field to the profile"
|
||||
})
|
||||
|
||||
# model_preference validation
|
||||
model = profile.get("model_preference", "")
|
||||
if model and model not in VALID_MODELS:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "model_preference"},
|
||||
"issue": f"Invalid model_preference '{model}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_MODELS))}"
|
||||
})
|
||||
|
||||
# tier validation
|
||||
tier = profile.get("tier", "")
|
||||
if tier and tier not in VALID_TIERS:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "tier"},
|
||||
"issue": f"Invalid tier '{tier}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_TIERS))}"
|
||||
})
|
||||
|
||||
# style_baseline length — model-aware
|
||||
baseline = profile.get("style_baseline", "")
|
||||
if isinstance(baseline, str):
|
||||
max_len = STYLE_BASELINE_MAX_V4 if model == "v4 Pro" else STYLE_BASELINE_MAX
|
||||
if len(baseline) > max_len:
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "style_baseline"},
|
||||
"issue": f"style_baseline is {len(baseline)} chars (max {max_len} for {model or 'this model'})",
|
||||
"fix": f"Trim style_baseline to {max_len} characters. Front-load essential descriptors in the first 200 chars."
|
||||
})
|
||||
|
||||
# vocal section — skip required checks if instrumental
|
||||
vocal = profile.get("vocal", {})
|
||||
if not is_instrumental:
|
||||
if not isinstance(vocal, dict):
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "field": "vocal"},
|
||||
"issue": "'vocal' must be a mapping",
|
||||
"fix": "Define vocal as a YAML mapping with gender, tone, delivery, energy fields"
|
||||
})
|
||||
else:
|
||||
for vfield in ["gender", "tone", "delivery", "energy"]:
|
||||
val = vocal.get(vfield)
|
||||
if not val or not isinstance(val, str) or not val.strip():
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "field": f"vocal.{vfield}"},
|
||||
"issue": f"Required vocal field '{vfield}' is missing or empty",
|
||||
"fix": f"Add a non-empty 'vocal.{vfield}' field (or set instrumental: true for instrumental projects)"
|
||||
})
|
||||
|
||||
gender = vocal.get("gender", "")
|
||||
if gender and gender not in VALID_GENDERS:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "vocal.gender"},
|
||||
"issue": f"Invalid vocal gender '{gender}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_GENDERS))}"
|
||||
})
|
||||
elif isinstance(vocal, dict):
|
||||
# Instrumental but vocal present — validate gender if provided
|
||||
gender = vocal.get("gender", "")
|
||||
if gender and gender not in VALID_GENDERS:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "vocal.gender"},
|
||||
"issue": f"Invalid vocal gender '{gender}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_GENDERS))}"
|
||||
})
|
||||
|
||||
# Tier-model consistency
|
||||
if tier == "free" and model and model != FREE_TIER_MODEL:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "model_preference"},
|
||||
"issue": f"Free tier can only use '{FREE_TIER_MODEL}', but profile specifies '{model}'",
|
||||
"fix": f"Change model_preference to '{FREE_TIER_MODEL}' or upgrade tier"
|
||||
})
|
||||
|
||||
# Slider warnings for free tier
|
||||
sliders = profile.get("sliders", {})
|
||||
if tier == "free" and isinstance(sliders, dict) and sliders:
|
||||
has_values = any(
|
||||
k in ("weirdness", "style_influence") and v is not None and v != 50
|
||||
for k, v in sliders.items()
|
||||
)
|
||||
if has_values:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "sliders"},
|
||||
"issue": "Slider values set but free tier does not support Weirdness/Style Influence sliders",
|
||||
"fix": "Remove sliders section or upgrade to Pro/Premier tier"
|
||||
})
|
||||
|
||||
# Slider range validation
|
||||
if isinstance(sliders, dict):
|
||||
for sname in ["weirdness", "style_influence", "audio_influence"]:
|
||||
sval = sliders.get(sname)
|
||||
if sval is not None:
|
||||
if not isinstance(sval, (int, float)) or sval < 0 or sval > 100:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": f"sliders.{sname}"},
|
||||
"issue": f"Slider '{sname}' value {sval} out of range",
|
||||
"fix": "Must be an integer between 0 and 100"
|
||||
})
|
||||
|
||||
# Exclusion defaults length check
|
||||
exclusions = profile.get("exclusion_defaults", [])
|
||||
if isinstance(exclusions, list):
|
||||
if len(exclusions) > 5:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "exclusion_defaults"},
|
||||
"issue": f"{len(exclusions)} exclusions defined (recommended max 5)",
|
||||
"fix": "Too many negatives can confuse the model. Prioritize the most important."
|
||||
})
|
||||
|
||||
# creativity_default validation
|
||||
creativity = profile.get("creativity_default")
|
||||
if creativity is not None:
|
||||
if not isinstance(creativity, str) or creativity not in VALID_CREATIVITY:
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "creativity_default"},
|
||||
"issue": f"Invalid creativity_default '{creativity}'",
|
||||
"fix": f"Must be one of: {', '.join(sorted(VALID_CREATIVITY))}"
|
||||
})
|
||||
|
||||
# language validation
|
||||
language = profile.get("language")
|
||||
if language is not None:
|
||||
if not isinstance(language, str) or not language.strip():
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "language"},
|
||||
"issue": "language field is present but empty",
|
||||
"fix": "Provide a language value (e.g., 'English', 'Spanish') or remove the field"
|
||||
})
|
||||
|
||||
# generation_history validation
|
||||
gen_history = profile.get("generation_history")
|
||||
if gen_history is not None:
|
||||
if not isinstance(gen_history, list):
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "field": "generation_history"},
|
||||
"issue": "generation_history must be a list",
|
||||
"fix": "Set generation_history to a list of snapshot entries"
|
||||
})
|
||||
elif len(gen_history) > MAX_GENERATION_HISTORY:
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "generation_history"},
|
||||
"issue": f"generation_history has {len(gen_history)} entries (max {MAX_GENERATION_HISTORY})",
|
||||
"fix": f"Keep only the {MAX_GENERATION_HISTORY} most recent or significant entries"
|
||||
})
|
||||
|
||||
# studio_preferences validation — warn if not premier
|
||||
studio = profile.get("studio_preferences", {})
|
||||
if isinstance(studio, dict) and any(v is not None and v != "" for v in studio.values()):
|
||||
if tier and tier != "premier":
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "studio_preferences"},
|
||||
"issue": f"Studio preferences set but '{tier}' tier does not support Studio features",
|
||||
"fix": "Remove studio_preferences or upgrade to Premier tier"
|
||||
})
|
||||
# Validate BPM if present
|
||||
bpm = studio.get("bpm")
|
||||
if bpm is not None and not isinstance(bpm, (int, float)):
|
||||
findings.append({
|
||||
"severity": "low",
|
||||
"category": "consistency",
|
||||
"location": {"file": str(profile_path), "field": "studio_preferences.bpm"},
|
||||
"issue": f"BPM must be a number, got {type(bpm).__name__}",
|
||||
"fix": "Set bpm to a numeric value (e.g., 120)"
|
||||
})
|
||||
|
||||
# Build summary
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
for f in findings:
|
||||
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
|
||||
|
||||
# Per-band playlist YAML check: if the band has any songbook entries,
|
||||
# `docs/{band-slug}-playlist.yaml` MUST exist as the canonical source of
|
||||
# truth for playlist sequencing. Multi-band projects need this to keep
|
||||
# bands independent (see playlist-sequencing-methodology.md "Per-Band
|
||||
# Playlist YAML" section).
|
||||
band_slug = profile_path.stem # e.g., docs/band-profiles/lennys-voice.yaml -> lennys-voice
|
||||
project_root = profile_path.parent.parent.parent # band-profiles -> docs -> project_root
|
||||
songbook_dir = project_root / "docs" / "songbook" / band_slug
|
||||
playlist_yaml = project_root / "docs" / f"{band_slug}-playlist.yaml"
|
||||
if songbook_dir.is_dir() and any(songbook_dir.glob("*.md")):
|
||||
if not playlist_yaml.exists():
|
||||
findings.append({
|
||||
"severity": "high",
|
||||
"category": "structure",
|
||||
"location": {"file": str(profile_path), "expected_file": str(playlist_yaml)},
|
||||
"issue": (
|
||||
f"Band has songbook entries at {songbook_dir} but no canonical "
|
||||
f"playlist YAML at {playlist_yaml}. Per-band playlist YAML is the "
|
||||
f"single source of truth for sequencing."
|
||||
),
|
||||
"fix": (
|
||||
f"Run `python3 src/skills/suno-band-profile-manager/scripts/scaffold-playlist.py "
|
||||
f"{band_slug} --from-songbook` to bootstrap from songbook entries, then fill in "
|
||||
f"audio file names and order. See profile-schema.md 'Per-Band Playlist YAML' section."
|
||||
),
|
||||
})
|
||||
|
||||
# Deprecated: in-profile `playlist:` block. Per v1.7.2 the band profile
|
||||
# should NOT carry playlist data — that lives in docs/{band-slug}-playlist.yaml.
|
||||
if "playlist" in profile and isinstance(profile["playlist"], dict):
|
||||
findings.append({
|
||||
"severity": "medium",
|
||||
"category": "deprecation",
|
||||
"location": {"file": str(profile_path), "field": "playlist"},
|
||||
"issue": (
|
||||
"The `playlist:` block in the band profile is DEPRECATED as of v1.7.2. "
|
||||
"Playlist data must live in docs/{band-slug}-playlist.yaml as the single "
|
||||
"source of truth, otherwise the two locations drift independently."
|
||||
),
|
||||
"fix": (
|
||||
f"Move authoritative track list to docs/{band_slug}-playlist.yaml (or run "
|
||||
f"scaffold-playlist.py to bootstrap), then remove the `playlist:` block "
|
||||
f"from this profile YAML. Sequencing-history narrative notes can move to "
|
||||
f"the band's playlist-ordering.md if you maintain one."
|
||||
),
|
||||
})
|
||||
|
||||
# Re-tally severity counts after the playlist checks above
|
||||
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
for f in findings:
|
||||
sev = f.get("severity", "low")
|
||||
if sev in severity_counts:
|
||||
severity_counts[sev] += 1
|
||||
|
||||
status = "pass"
|
||||
if severity_counts["critical"] > 0:
|
||||
status = "fail"
|
||||
elif severity_counts["high"] > 0:
|
||||
status = "fail"
|
||||
elif severity_counts["medium"] > 0:
|
||||
status = "warning"
|
||||
|
||||
return {
|
||||
"script": script_name,
|
||||
"version": "2.1.0",
|
||||
"skill_path": str(profile_path),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"status": status,
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"total": len(findings),
|
||||
**severity_counts
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate a band profile YAML file against the profile schema.",
|
||||
epilog="Exit codes: 0=pass, 1=fail, 2=error"
|
||||
)
|
||||
parser.add_argument("profile_path", nargs="?", help="Path to the band profile YAML file")
|
||||
parser.add_argument("-o", "--output", help="Output file (defaults to stdout)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print diagnostics to stderr")
|
||||
parser.add_argument(
|
||||
"--derive-filename",
|
||||
metavar="BAND_NAME",
|
||||
help="Convert a band name to kebab-case filename and exit"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.derive_filename:
|
||||
result = {
|
||||
"band_name": args.derive_filename,
|
||||
"filename": derive_filename(args.derive_filename),
|
||||
}
|
||||
output = json.dumps(result, indent=2)
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
else:
|
||||
print(output)
|
||||
sys.exit(0)
|
||||
|
||||
if not args.profile_path:
|
||||
parser.error("profile_path is required when not using --derive-filename")
|
||||
|
||||
profile_path = Path(args.profile_path)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Validating profile: {profile_path}", file=sys.stderr)
|
||||
|
||||
result = validate_profile(profile_path)
|
||||
output = json.dumps(result, indent=2)
|
||||
|
||||
if args.output:
|
||||
Path(args.output).write_text(output)
|
||||
if args.verbose:
|
||||
print(f"Results written to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
if result["status"] == "fail":
|
||||
sys.exit(1)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user