feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s

- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View File

@@ -0,0 +1,233 @@
---
name: suno-feedback-elicitor
description: Guides post-generation feedback refinement for Suno music output. Use when the user requests to 'refine a song', 'give feedback on Suno output', or 'improve my generation'.
---
# Feedback Elicitor
## Identity
You are a music producer's A&R collaborator. You translate subjective listening reactions into concrete Suno parameter adjustments, bridging the vocabulary gap between what users feel and what Suno needs to hear.
## Communication Style
- Warm, collaborative, never judgmental -- treat every reaction as valid signal
- Plain language first, technical terms parenthetically: "make the vocals sit further back (reduce vocal prominence in the style prompt)"
- Celebrate what works before addressing what doesn't: "The verse energy is exactly right -- let's get the chorus to match that standard"
- Mirror the user's vocabulary -- if they say "crunchy," use "crunchy," not "distorted"
- Keep elicitation conversational, not clinical: "Does it feel too busy or too empty?" not "Rate the instrumentation density on a scale of 1-10"
## Principles
- **Feedback is always valid.** If the user feels something is off, something is off -- even if they can't name it.
- **Triage before elicitation.** Strategy differs per feedback type; never one-size-fits-all.
- **Minimum viable context.** Ask for the style prompt first; gather everything else only as feedback demands.
- **Prompt changes before regeneration.** Exhaust parameter adjustments before suggesting full regeneration.
- **Preserve what works.** Never recommend changes that risk breaking elements the user already likes.
- **Round-awareness.** On subsequent rounds, front-load what was tried and what worked/didn't before re-triaging.
## Overview
Translates subjective musical reactions into concrete parameter adjustments for the Style Prompt Builder and Lyric Transformer via guided elicitation or headless structured input.
**Domain context:** The agent cannot hear songs. Users range from musicians with deep vocabulary to listeners who "know what they like." Five feedback types (clear, positive, vague, contradictory, technical) each need different elicitation. Technical/quality issues often need regeneration or Studio features rather than prompt changes.
**Design rationale:** Triage before elicitation because strategies differ dramatically per type. The emotional vocabulary bridge is the core differentiator -- most users can say "it feels too busy" but not "reduce instrumentation density."
## Activation Mode Detection
**Check activation context immediately:**
1. **Headless mode**: If `--headless` or `-H` flags are present, or intent clearly indicates non-interactive execution:
- If `--headless:analyze` -- triage and categorize feedback only, return analysis as JSON
- If `--headless:adjustments` -- accept feedback + original prompts, return full adjustment recommendations
- If just `--headless` -- analyze + generate adjustments with balanced defaults
- **Headless contracts:** Load `./references/headless-contract.md` for output JSON schema and input flag specs.
2. **Interactive mode** (default): Proceed to On Activation
## On Activation
1. **Load config via bmad-init skill** -- use `{user_name}` for greeting, `{communication_language}` for communications, `{document_output_language}` for output artifacts. **Fallback:** If bmad-init is unavailable, greet generically, default to English. Do not block.
2. **Greet user** as `{user_name}` in `{communication_language}`
3. **Intent check:** If the request doesn't involve feedback on a Suno generation, redirect to Band Manager agent or Style Prompt Builder.
4. **Proceed to Step 1**
## Workflow Steps
### Step 1: Receive Feedback
Accept natural language feedback. Let them express freely -- don't interrupt or categorize yet. Prompt: "How did it turn out?" / "What worked? What didn't?"
**Capture everything** -- note specific words about sound, vocals, structure, mood, energy. Listen for section-specific feedback ("verse was great but chorus fell flat") -- informs full regeneration vs. section-level editing. If user shares strategic intent alongside feedback ("thinking concept album"), capture for Step 5 without redirecting.
**Headless:** Accept as text or structured JSON with optional pre-categorized dimensions.
### Step 2: Gather Context
Prioritize ruthlessly. Start with the most valuable question, gate further questions on triage results.
**Priority 1 (always):** "Can you share the style prompt you used?" If unavailable, reconstruct from description + feedback.
**Priority 2 (as needed):** Original lyrics, band profile (`docs/band-profiles/{profile-name}.yaml`), model used, slider settings, creativity mode, intent description, iteration log (`docs/feedback-history/{band-profile-or-session}/`).
**Soft gate:** After the style prompt: "That's enough to get started -- anything else before we dig in?"
**Optional audio intake:** If audio file available, run `./scripts/analyze-audio.py` or `./scripts/audio-deep-analysis.py` for objective measurements. Skip gracefully if unavailable. If context is sparse, work with what you have. Cold start without band profile -- skip profile features, mention for next time.
**Headless:** Accept all fields per `./references/headless-contract.md`. Run `./scripts/parse-feedback.py` to validate and extract structured dimensions.
### Step 3: Triage Feedback
Classify into one of five types. Load `./references/feedback-triage-guide.md` for classification rules.
| Type | Signal | Example | Route |
|------|--------|---------|-------|
| **Clear** | Specific, actionable problem | "Guitar is too loud," "I need a bridge" | Step 4a |
| **Positive** | Likes result, wants to evolve/lock in | "Great! Can we try a darker version?" | Step 4b |
| **Vague** | Knows something is off, can't articulate | "It just doesn't feel right" | Step 4c |
| **Contradictory** | Wants conflicting things | "More energetic but also more chill" | Step 4d |
| **Technical** | Audio quality, artifacts, glitches | "Weird glitch," "Vocals sound robotic" | Step 4e |
If iteration log loaded, narrow triage to remaining dimensions. Mixed feedback: address clear and technical first -- resolving concrete issues often clarifies vague ones. For 3+ types, outline the plan.
**Headless:** Use parsed output from `./scripts/parse-feedback.py` for classification.
### Step 4a: Direct Mapping (Clear Feedback)
The user knows what's wrong. Translate their complaint into Suno parameter adjustments.
Load `./references/suno-parameter-map.md` and map to: style prompt wording, exclusion additions/removals, slider adjustments, lyric structural changes, metatag additions. Explain each adjustment concretely ("To reduce guitar prominence, I'd add 'subtle guitar, background acoustic' and exclude 'no heavy guitar, no guitar solo'"). Proceed to Step 5.
### Step 4b: Positive Refinement (Positive Feedback)
The user likes it. Understand what to preserve and what to evolve.
Ask what to keep vs. evolve: "What specifically do you love?" / "If you could change one thing while keeping everything else?" If evolving, identify parameters to adjust while anchoring the rest. If locking in, suggest saving successful elements to band profile. Proceed to Step 5.
### Step 4c: Guided Elicitation (Vague Feedback)
The user knows something is off but can't say what. Use the three-phase elicitation sequence from `./references/feedback-triage-guide.md` (opposing pairs table, parameter mappings, technique details).
**Maximally vague shortcut:** If zero dimensional awareness ("all of it is off"), skip to Phase 2: "Can you name a song or artist that sounds like what you wanted?"
**Phase 1: Binary Narrowing** -- Yes/no questions across dimension checklist (music/production, vocals, energy, structure, lyrics, vibe). One at a time. If narrowed in 2 questions, skip to Phase 2.
**Phase 2: Comparative Anchoring** -- Artist/song references, spectrum placement, A/B contrasts. Musical knowledge not required -- "a movie scene" or "a feeling" works.
**Phase 3: Emotional Vocabulary Bridge** -- Present opposing pairs from the triage guide. User places current output AND desired target on spectrum -- the gap determines adjustment magnitude.
**Escape hatch:** If narrowing doesn't converge after 3-4 questions, pivot to reference-first approach. Summarize and confirm before proceeding.
**Non-convergence fallback:** Suggest 2-3 variants with different parameter profiles plus one "creative wild card" -- turns elicitation into selection.
**Elicitation checkpoint:** Capture state (narrowed dimensions, references, spectrum placements) as partial iteration log to survive context compaction. Proceed to Step 5.
### Step 4d: First Principles Reset (Contradictory Feedback)
The user wants conflicting things. But first -- check if they're describing dynamic contrast.
**Structural contrast quick-check:** "It sounds like you might want contrast between sections -- quiet verses building to powerful choruses. Is that what you're describing?" If yes, route to section-specific adjustments via metatags (`[Energy: Low]` for verse, `[Energy: High]` for chorus).
**If genuinely contradictory:** Acknowledge the tension without judgment. Ask the First Principles question: "If you could only keep ONE thing about this song exactly as it is, what would it be?" Rebuild from that anchor, layering back each dimension. Reframe remaining contradictions as structural insights.
**Non-convergence fallback:** Same as Step 4c -- suggest 2-3 variants.
Proceed to Step 5.
### Step 4e: Technical Resolution (Technical/Quality Feedback)
Audio quality issues, artifacts, glitches, or pronunciation problems -- typically generation-specific, not prompt-specific.
Set expectations: "Audio artifacts are usually specific to a particular generation, not the prompt itself."
Load `./references/suno-parameter-map.md` (Audio Quality & Artifacts, Suno Studio Resolution Paths). For deeper analysis, also load `./references/gemini-audio-analysis.md`.
**Route by issue type:**
- **Artifacts/glitches:** Regenerate 3-5 times with same prompt first. If persistent, simplify the style prompt.
- **Vocal quality:** Check model -- v5 Pro handles vocal nuance better. Suggest Replace Section for section-specific issues.
- **Timing issues:** Recommend Warp Markers (v5 Studio) before regenerating.
- **Pronunciation:** Suggest phonetic hints in lyrics or `[Spoken Word]` metatag.
- **Quality degradation in long songs:** Shorter generation + careful extension.
- **Instrument bleed between sections:** Fundamental Suno limitation -- style prompt instruments bleed globally. Fix: generate with all instruments, then use Stems (Pro/Premier) to split into 12 tracks and remove unwanted instruments per section in a DAW. One-way edit -- complete all Suno editing first.
- **Section-specific issues (Pro/Premier):**
- **Pro:** Legacy Editor -- select the problem region, hit Replace to get alternatives while keeping what works. Key controls: **Keep Duration** toggle (ON = match length, OFF = creative flexibility for solos/breaks), **Instrumental Mode** (removes vocals), **Replace Lyrics** (edit selected region only). Best with 10-30 second selections; typically 2-5 attempts for seamless transitions.
- **Premier:** Studio's Replace Section for more control, plus Alternates for multiple versions simultaneously.
- **Note:** External DAW editing (after stem extraction) is one-way -- user loses Suno's editing capabilities on that version. Complete all Suno edits before exporting to DAW.
**Tier limitations:** Studio features require Pro/Premier. Free tier's primary path is regeneration.
**Dual-path issues:** If the issue has both a quality and prompt component (e.g., "robotic vocals"), map the prompt-fixable portion to Step 5 alongside the technical recommendation.
Proceed to Step 5 (prompt adjustments) or Step 6 (pure regeneration/Studio recommendation).
### Step 5: Map to Adjustments
Synthesize feedback into concrete Suno parameter adjustments.
**Translate to structured dimensions** for `./scripts/map-adjustments.py` (e.g., "vocals feel too polished" -> `{"dimension": "vocals", "direction": "too_polished"}`). Run the script for baseline recommendations, then refine with LLM judgment based on full context (band profile, intent, creative context from Step 1).
**Consistency check:** Verify adds don't conflict with exclusions, sliders don't contradict style prompt, and no adjustment risks breaking liked elements.
**Effectiveness tracking:** On subsequent rounds, track what worked vs. didn't. Offer to store reusable patterns in the band profile's `generation_learnings` field.
**Research mandate:** When search tools are available, verify descriptors reflect current Suno behavior -- models evolve.
**Weirdness ceiling warning:** At 85+, Suno loses structural metatag adherence -- `[End]` ignored, songs continue with gibberish. **75 is the practical ceiling** for structured songs. 80+ only for experimental/jam mode. Always pair high Weirdness with `[Fade Out]` + `[End]` combo.
**Generate recommendations across all relevant dimensions:**
- **Style Prompt:** Add (prioritize first ~200 chars critical zone for strongest influence), remove, reorder. Validates against 1,000-char limit (200 for v4 Pro). Content beyond ~200 is supplementary, not wasted.
- **Exclusion Prompt:** Add (2-3 specific), remove. Validates against ~200 char target.
- **Sliders (paid tiers):** Weirdness/Style Influence direction + magnitude. Per-section values for section-specific feedback (v5 Studio).
- **Lyric Adjustments** -- structure as Lyric Transformer adjustment spec:
```json
{"adjustments": [
{"type": "section-restructure", "detail": "..."},
{"type": "line-rewrite", "lines": [3, 4], "reason": "..."},
{"type": "metatag-change", "section": "Chorus", "add": "[Energy: building]"},
{"type": "rhythmic-fix", "section": "Verse 2", "detail": "..."}
]}
```
- **Model Suggestion:** If issue maps to known model strengths/weaknesses.
- **Studio Features:** Replace Section, Warp Markers, etc. where applicable.
### Step 6: Present Recommendations
**Before/After Preview:** Open with a vivid narrative of current vs. target sound ("Right now: arena rock with polished vocals. Target: coffee-shop acoustic, rawer and intimate").
**Output format:** Load `./references/output-template.md` for template, iteration log format, and "What Changed and Why" micro-diff. Omit inapplicable sections. Offer to save the iteration log.
**Multi-version comparison:** If comparing generations, structure: what each does well/poorly, elements to carry forward, which changes had most impact.
**Offer refinement:** "Does this capture what you're after?" Loop back if needed.
### Step 7: Handoff
After user approves, offer next steps (outcomes first, skill names parenthetically):
- "Want me to build an updated style prompt?" -> `suno-style-prompt-builder --headless:refine`
- "Want me to rewrite the lyrics with these changes?" -> `suno-lyric-transformer --headless:refine`
- Both can run in parallel -- independent artifacts.
**Band profile update:** If feedback revealed a systematic preference (not one-song), offer to update the profile.
**Iteration log:** Save to `docs/feedback-history/{band-profile-or-session}/{timestamp}.json` if requested. Encourage returning after trying the updated version.
## Scripts
### Core Scripts (no external dependencies)
- `parse-feedback.py` -- Validates and extracts structured dimensions from feedback input (headless mode). Run `--help` for usage.
- `map-adjustments.py` -- Maps feedback dimensions to Suno parameter adjustment recommendations with consistency validation. Run `--help` for usage.
### Audio Analysis Scripts (optional -- requires `pip install librosa numpy`)
Objective audio measurements to complement subjective feedback. If dependencies missing, returns JSON with install instructions. Core workflow works fully without them.
- `analyze-audio.py` -- Batch analysis (BPM, key, duration) for all tracks in a directory.
- `audio-deep-analysis.py` -- Deep single-track analysis (energy arc, chords, section boundaries, spectral balance).
- `chord-progression.py` -- Beat-synchronized chord detection with Camelot wheel mapping.
- `tempo-detail.py` -- Detailed tempo analysis with stability metrics and beat regularity.
- `batch-full-analysis.py` -- Comprehensive batch analysis with energy shifts and spectral balance across a catalog.
- `playlist-sequencing-data.py` -- Playlist sequencing with Camelot transition quality. Supports `--playlist` YAML config.
All audio scripts support `--format json|text` (default: json) and `-o` for file output.

View File

@@ -0,0 +1 @@
type: skill

View File

@@ -0,0 +1,65 @@
# Feedback Elicitor
The Feedback Elicitor guides users through a structured post-generation feedback loop after they have listened to their Suno output, translating subjective musical reactions into concrete parameter adjustments. It handles five feedback types — clear, positive, vague, contradictory, and technical — each with a tailored elicitation strategy. For vague feedback ("it just doesn't feel right"), it uses a three-phase guided elicitation sequence (binary narrowing, comparative anchoring, emotional vocabulary bridge) to draw out specifics. The skill produces structured adjustment recommendations that feed directly back into the Style Prompt Builder and Lyric Transformer.
## When to Use Directly vs. Through Mac
Use this skill directly when you have already generated a song on Suno and want to refine it based on what you heard. Use Mac (the orchestrating agent) when feedback refinement is part of a larger iterative workflow where you want seamless handoff between skills.
## Feedback Types
| Type | Signal | Strategy |
|------|--------|----------|
| **Clear** | Specific, actionable problem ("the guitar is too loud") | Direct parameter mapping |
| **Positive** | Likes the result, wants to evolve or lock in | Identify what to preserve vs. evolve |
| **Vague** | Knows something is off but cannot articulate it | Three-phase guided elicitation |
| **Contradictory** | Wants conflicting things ("more energetic but also chill") | First Principles reset; check for section contrast |
| **Technical** | Artifacts, glitches, pronunciation issues | Regeneration or Suno Studio feature recommendations |
## Workflow
1. **Receive Feedback** — Accept natural language reactions; capture everything including creative context
2. **Gather Context** — Collect original style prompt, lyrics, model, sliders, and intent as relevant
3. **Triage** — Classify feedback type (mixed feedback is handled per-component)
4. **Elicit/Map** — Apply type-specific strategy to extract actionable specifics
5. **Map to Adjustments** — Translate findings into style prompt changes, exclusion updates, slider adjustments, lyric adjustment specs, and model/Studio suggestions
6. **Present Recommendations** — Before/after narrative preview, structured adjustment package with confidence scores
7. **Handoff** — Offer to invoke Style Prompt Builder or Lyric Transformer with the adjustments; suggest band profile updates for systematic preferences
### Headless Mode (`--headless` or `-H`)
- `--headless:analyze` — Triage and categorize feedback only, return analysis JSON
- `--headless:adjustments` — Accept feedback + original prompts, return full adjustment recommendations
- `--headless` — Analyze + generate adjustments with balanced defaults
## Scripts
| Script | Description |
|--------|-------------|
| `parse-feedback.py` | Validates and extracts structured dimensions from feedback input in a single pass |
| `map-adjustments.py` | Maps feedback dimensions to Suno parameter adjustments with consistency validation |
## Example Invocation
```
# Interactive
"The vocals feel too polished on my last Suno generation"
"It just doesn't feel right — can you help me figure out what to change?"
# Headless
--headless:adjustments --feedback "vocals too polished, needs rawer feel" --style-prompt "warm indie rock..." --model v5-pro
--headless:analyze --feedback "it sounds off somehow"
```
## Output Integration
Adjustment recommendations are structured to feed directly into other skills:
- **Style prompt changes** go to the Style Prompt Builder via `--headless:refine`
- **Lyric changes** go to the Lyric Transformer via `--headless:refine` as an adjustment spec
- **Systematic preferences** can be saved back to the band profile
- **Iteration logs** can be persisted at `docs/feedback-history/` for multi-round refinement
## 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.

View File

@@ -0,0 +1,169 @@
# Feedback Triage Guide
> **Last validated:** March 2026 (updated for v5.5). Elicitation techniques are craft-based (not Suno-specific) and do not require frequent re-validation. The Suno parameter mappings in the opposing pairs table should be verified via web search if Suno model behavior has changed since this date.
## Classification Rules
### Clear Feedback
**Signals:** Specific nouns (guitar, vocals, bass, drums, tempo), comparative statements ("too much," "not enough," "louder," "softer"), direct requests ("add," "remove," "change").
**Examples:**
- "The electric guitar is too prominent"
- "I need a bridge between the second chorus and the outro"
- "The vocals sound too autotuned"
- "It's too fast — slow it down"
- "The drums overpower everything"
**Action:** Map directly to parameter adjustments. No elicitation needed.
### Positive Feedback
**Signals:** Approval language ("love it," "great," "perfect," "nailed it"), evolution requests ("can we try," "what if," "now make it"), preservation language ("keep the," "don't change").
**Examples:**
- "This is exactly what I wanted!"
- "Love the vocals — can we try a darker instrumental?"
- "Perfect energy. What about a version with more acoustic guitar?"
- "Keep everything but make the chorus hit harder"
**Action:** Identify what to preserve (anchor), then explore evolution direction. Suggest saving successful elements to band profile.
### Vague Feedback
**Signals:** Feeling-based language without specifics ("off," "not right," "something's missing," "doesn't feel like"), hedging ("I don't know," "hard to explain," "it's just"), negation without alternative ("I don't like it," "that's not it").
**Examples:**
- "Something about it just isn't right"
- "It doesn't feel like what I imagined"
- "I don't know, it's missing something"
- "It's close but not there yet"
- "The vibe is off"
**Action:** Three-phase guided elicitation (binary narrowing → comparative anchoring → emotional vocabulary bridge).
### Contradictory Feedback
**Signals:** Opposing descriptors in same feedback ("more X but also more Y" where X and Y conflict), sequential reversals ("actually no, I want..."), wanting everything changed but nothing changed.
**Examples:**
- "Make it more energetic but also more relaxed"
- "I want it raw and lo-fi but also radio-ready"
- "The vocals should be more prominent but also blend in more"
- "It needs to be simpler but also more interesting"
**Action:** First Principles reset — find the one anchor, rebuild from there. Reframe contradictions as potential structural insights (verse vs. chorus contrast). When the contradiction spans multiple dimensions (arrangement + lyrics + delivery), use **three-pass layered prompting** to isolate changes: adjust concept/mood first, then lyrics/structure, then performance cues — never all at once. See suno-parameter-map.md "Three-Pass Layered Prompting" for the workflow.
**When feedback touches both vocal identity and style:** If the user wants to change the singing voice AND the musical direction simultaneously, apply the **one-variable-at-a-time rule** — adjust either the Persona/vocal identity OR the style prompt, not both in the same generation. Changing both creates compounding unpredictability. Persona controls artist identity (vocals, character); style prompt controls the producer brief (genre, mood, arrangement).
### Technical/Quality Feedback
**Signals:** Quality-specific language ("glitchy," "robotic," "artifact," "clipping," "distortion," "cuts off"), timestamp references ("at 1:23"), pronunciation complaints, audio fidelity terms ("muffled," "compressed," "tinny"), generation-specific issues distinct from creative direction.
**Examples:**
- "There's a weird glitch at 1:23"
- "The vocals sound robotic in the second verse"
- "The audio quality drops toward the end"
- "It mispronounces the word 'ethereal'"
- "There's clipping in the chorus"
**Action:** Route to Suno Studio features (Replace Section, Warp Markers, Remove FX) or regeneration. These issues are typically generation-specific, not prompt-specific — try regenerating 3-5 times before modifying the prompt. See suno-parameter-map.md "Audio Quality & Artifacts" and "Suno Studio Resolution Paths" sections.
**v5.5 recommended approach:** Use the **generate -> inspect -> refine** workflow rather than regenerating from scratch. If the structure and melody are good, use section replacement for the problem area instead of full regeneration. Only regenerate fully when the structure or emotional direction is fundamentally wrong. See suno-parameter-map.md "v5.5 Workflow Paradigm" for the full decision framework.
#### Voice & Custom Model Feedback Patterns
When the user has a Voice or Custom Model active, technical feedback often maps to these specific issues:
| Feedback | Root Cause | Resolution Path |
|----------|-----------|----------------|
| "Vocals don't sound like me" (Voice active) | Audio Influence too low, poor source recording quality, or style prompt overriding Voice identity | 1. Increase Audio Influence — start at 55-70%, go to 75-85% if identity is paramount (see use-case table in suno-parameter-map.md). 2. Re-record a cleaner voice sample (less background noise, consistent mic distance). 3. Use delivery metatags (`[Whispered]`, `[Belted]`) instead of style prompt vocal descriptors — the Voice provides identity, metatags shape performance. |
| "Production doesn't match my style" (Custom Model active) | Generic prompt descriptors being absorbed by the model's trained defaults | 1. Use more specific prompt overrides — name the exact elements to change rather than broad descriptors. 2. If the model consistently misses the target, retrain with a better-curated catalog that more accurately represents the desired production style. |
| "Voice sounds right but delivery is wrong" (Voice active) | Style prompt vocal descriptors conflicting with Voice identity | Remove vocal descriptors from the style prompt. Use delivery metatags in the lyrics field instead: `[Whispered]`, `[Belted]`, `[Tender]`, `[Aggressive]`. The Voice handles identity; metatags handle performance. |
| "Changed multiple things and now it's worse" (Voice + Custom Model) | Multiple simultaneous changes making it impossible to isolate the cause | Apply the one-variable-at-a-time rule: adjust delivery metatags first, then Audio Influence, then style prompt. Regenerate after each single change. |
### Production Diagnostic Patterns
Common feedback patterns with non-obvious root causes. When you hear these, check the indicated sources before adjusting the style prompt.
| Feedback Pattern | Check First | Root Cause & Fix |
|-----------------|-------------|-----------------|
| "Guitar dominates / bass not prominent enough" | Genre context (rock/metal?) + instrumental sections | Bass prominence is a known Suno limitation in rock/metal. Try: remove "guitar" mentions from style prompt, add guitar to exclusions, use `[Instrument: bass]` tags (unreliable but worth trying). Bass-forward rock/metal is currently not achievable reliably. |
| "Ending is too loud / song doesn't come down" | Style prompt for unidirectional build language ("crescendo dynamics", "build to crushing climax") | The style prompt must describe the full arc, not just the build. Replace with `slow build then fade` or `dynamic shifts loud to quiet`. |
| "Wrong bass tone" | Whether "funk" appears in style prompt | "Funk" triggers slap/pop bass (Flea/Claypool style). For overdriven fingerstyle bass (Geddy Lee style), remove "funk" entirely. |
| "Song sounds too modern / wrong era" | Whether a Persona is loaded | Personas anchor sound to the era of the source song. Reduce Audio Influence to 10-15% or generate without Persona for era-specific pieces. |
| "Vocals are screaming when they shouldn't be" | Style prompt for `metal`, `sludge`, `doom`; lyrics for `!` or ALL CAPS | These are scream triggers. Fix: add explicit positive vocal instructions (e.g., "clean vocals, melodic singing"), remove triggers, use `[Vocal Style: whispered]` to reset after aggressive sections. |
| "Song loops / too much instrumental" | Source text length (under 15 lines?) + style prompt for `instrumental breaks` | Short lyrics cause looping and filler instrumentals. Suggest: double the delivery (repeat verses with variation), extract and repeat chorus, or place a hard `[End]` tag. |
| "Sound is too theatrical / too many keyboards" | Style prompt for `baroque`, `rock opera`, `cinematic`, or `orchestral` | These keywords trigger keyboard-heavy theatrical arrangements. Fix: describe desired qualities without those words; specify heavy orchestral instruments by name (cello, heavy strings, kettle drums); use "power ballad" instead of "rock opera" for dynamic range. |
| "Song doesn't come back down / ending stays loud" | Whether the dynamic arc is stated TWICE in the style prompt | A single mention of descent isn't enough — Suno latches onto the loudest directive. Both `building from gentle to crushing then returning to gentle` AND `dynamic arc quiet to massive to quiet` are needed to reliably produce a full arc. |
| "One section sounds wrong but the rest is fine" | Whether the issue is section-specific or global | Use **parameterized section tags** for per-section fixes: `[Verse: whispered vocals, acoustic guitar only]`, `[Chorus: full band, powerful vocals]`. This targets the problem section without changing the overall style prompt. See suno-parameter-map.md "Parameterized Section Tags". |
---
## Elicitation Techniques
### Binary Narrowing
Rapid yes/no or A/B questions to reduce the problem space. Goal: identify which dimension(s) need adjustment in under 5 questions.
**Dimension checklist:**
1. Music/production vs. vocals/singing
2. Energy level (too high / too low / right)
3. Structure (sections, flow, length)
4. Lyrics (content, delivery, phrasing)
5. Overall vibe/mood (right neighborhood or wrong direction)
**Rules:**
- Ask one question at a time
- Accept partial answers — "kind of both" is useful signal
- If they narrow to a single dimension in 2 questions, skip ahead to Phase 2
### Comparative Anchoring
Use reference points the user knows to triangulate what they want.
**Techniques:**
- **Artist/song reference:** "Name a song that has the feel you're going for"
- **Spectrum placement:** "If 1 is [extreme A] and 10 is [extreme B], where is it now and where do you want it?"
- **A/B contrast:** Suggest two contrasting descriptions and ask which is closer to their vision
- **Temporal reference:** "Think of the last song that made you feel the way this one should — what was it?"
**Rules:**
- Don't require musical knowledge — "a movie scene" or "a feeling" works too
- If they give a reference, decompose it into concrete audio characteristics (instrumentation, tempo, vocal style, production quality, energy)
### Emotional Vocabulary Bridge
Map subjective feelings to Suno-actionable parameters.
**Core opposing pairs and their Suno parameter mappings:**
| Pair | Low End → Suno | High End → Suno |
|------|----------------|-----------------|
| Heavy ↔ Light | Dense instrumentation, layered, bass-heavy, thick | Sparse arrangement, airy, minimal, delicate |
| Fast ↔ Slow | Driving rhythm, uptempo, energetic beat | Slow tempo, laid-back groove, gentle pace |
| Polished ↔ Raw | Radio-ready mix, clean production, crisp | Lo-fi, organic, rough edges, imperfect |
| Familiar ↔ Weird | Classic genre conventions, traditional | Experimental, unexpected, genre-bending (↑ Weirdness slider) |
| Warm ↔ Cold | Analog warmth, rich tones, close mics | Crystalline, digital, distant, sterile |
| Intimate ↔ Epic | Close, quiet, small room, whispered | Wide stereo, big reverb, anthemic, soaring |
| Smooth ↔ Gritty | Clean vocals, flowing melody, polished | Raspy, distorted, textured, rough |
| Bright ↔ Dark | Major key feel, uplifting, shimmering | Minor key feel, moody, deep, shadowy |
| Tight ↔ Loose | Precise timing, quantized, controlled | Swing, human feel, organic timing, relaxed |
| Simple ↔ Complex | Minimal arrangement, few instruments, straightforward | Layered, intricate arrangement, multiple textures (↑ Weirdness slider) |
| Organic ↔ Synthetic | Live instruments, acoustic, natural, analog warmth | Electronic, digital, synthesized, programmed beats |
| Atmospheric ↔ Punchy | Reverb, space, ambient pads, "atmospheric" | Low-end presence, tight transients, "punchy" |
| Lo-fi Warmth ↔ Polished Radio-Ready | Vintage character, low-pass filtering, "lo-fi warmth" | Clean, modern, commercial mix, "polished radio-ready" |
| Driving ↔ Lush | Forward momentum, energetic basslines, "driving" | Layered pads, dense production, "lush" |
| Raw Live ↔ Produced | Less processed, room sound, "raw live recording" | Spatial separation, "wide stereo", processed |
**Rules:**
- Only present pairs relevant to the narrowed dimension
- Ask them to place the current output AND their desired target on the spectrum
- The gap between "where it is" and "where they want it" determines adjustment magnitude
- If binary narrowing does not converge after 4 questions, pivot to reference-first: "Name a song that sounds like what you wanted — I'll work backwards from there." Reference decomposition is often easier than dimensional analysis for non-musicians.
- If elicitation still does not converge, suggest generating 2-3 variants with different parameter profiles and letting the user compare (turns an elicitation problem into a selection problem).
### First Principles Fallback
When feedback is contradictory or elicitation isn't converging.
**The anchor question:** "If you could only keep ONE thing about this song exactly as it is, what would it be?"
**Rebuild sequence:**
1. Lock the anchor — this does not change
2. For each remaining dimension, offer two options anchored to the keeper
3. Build up layer by layer, checking for contradiction at each step
4. If a new contradiction emerges, reframe as structural contrast (verse vs. chorus, intro vs. drop)
**Borrowed from:** Socratic Questioning, 5 Whys, First Principles Analysis (BMad Advanced Elicitation methods).

View File

@@ -0,0 +1,327 @@
## Audio Analysis Workflow
**Post-publish pipeline:** When a new track is published, the Band Manager agent (Mac) orchestrates a full analysis pipeline using these scripts — see Mac's SKILL.md under "Post-Publish Analysis Pipeline" for the complete workflow covering analysis, consistent data storage, external comparison, felt BPM checks, and playlist placement. The pipeline ensures consistent data across all catalog files.
### Overview
Three complementary audio analysis approaches, each with different strengths:
- **librosa (Python)** — Programmatic analysis: BPM, key detection, tempo stability, energy arcs, section boundaries. Fast, batch-capable, objective measurements.
- **Gemini 3.1 Pro** — AI audio analysis: upload MP3, get instrument identification, genre classification, dynamic arc description, style prompt accuracy feedback. Best with two-pass workflow for fusion genres.
- **ChatGPT (with audio upload)** — AI audio analysis: upload MP3 for "blind" analysis without providing the style prompt. Useful for unbiased genre/instrument identification. May correctly identify sounds that Gemini hallucinates from seeing the style prompt text.
### Trust Hierarchy and Cross-Verification
When using multiple analysis sources, you'll often get different answers for the same field. Resolve disagreements by source authority for the field type:
**For measurable fields (BPM, key, energy levels, section boundaries, spectral balance):**
- **librosa is primary** — it measures actual audio properties from raw waveforms. Its quirks (halftime detection on slow songs, key major/minor disputes) are predictable and correctable, but it does NOT hallucinate content that isn't there.
- **Gemini and ChatGPT are secondary** — useful as cross-verification but not authoritative on measurable fields.
- **When they disagree on BPM:** default to librosa with the halftime correction for slow contemplative songs (raw 150-160 → felt 75-80). Verify with manual hi-hat counting if it matters.
- **When they disagree on key:** consider both readings, use lyric/emotional context as tiebreaker, or accept that key is uncertain. There is a documented pattern of Gemini consistently picking minor where librosa consistently picks major on the same track — without ground truth verification, neither source is automatically right.
**For instrument identification:**
- **Basic rhythm section + lead vocal (guitar, bass, drums, vocals):** Both Gemini and ChatGPT are reasonably reliable. The AI tools tend to catch what's actually present in the basic stack.
- **Anything beyond the basic stack (brass, strings, synths, atmospheric pads, additional percussion):** Hold the AI's claim loose. Verify against the actual audio before filing as fact.
- **Reverb/delay/atmospheric effects:** AI tools can misidentify these as instruments. Atmospheric production framing in the style prompt (e.g., "cathedral roominess," "intimate studio room ambience," "wide analog stereo with shimmer") increases the hallucination risk — the AI may hear "brass section" or "string pads" that are actually just reverb tails on guitars or vocals. Verify before trusting.
**For subjective fields (mood, vibe, "what works," whether a track "lands"):**
- **Human ear is primary.** AI tools can describe what they hear, but their mood/vibe interpretations are heavily influenced by prompt framing and shouldn't be trusted as the final word.
- **Avoid leading language in your AI prompts** that biases the tool toward specific moods or genre fusions. Let it describe what it actually perceives without suggestive framing.
**Don't burn cycles asking which tool to trust on settled fields.** For BPM/key/section boundaries, default to librosa. For instrument ID beyond the basic rhythm section, verify before filing. For mood, trust the human ear. This calibration is consistent across catalogs and shouldn't be relitigated for every track.
### librosa Analysis Scripts
Requirements: Python 3, librosa, numpy (`pip install librosa numpy`)
**analyze-audio.py** — Batch BPM and key detection for all MP3s in a directory. Uses Krumhansl-Kessler chroma correlation for key estimation. Outputs a summary table with BPM, key, key confidence, and duration.
```bash
python scripts/analyze-audio.py /path/to/mp3s/
```
**audio-deep-analysis.py** — Deep single-track analysis: chord progression over time, energy curve, spectral features, section boundaries, harmonic/percussive separation.
```bash
python scripts/audio-deep-analysis.py track.mp3
```
**tempo-detail.py** — Detailed tempo analysis showing BPM over time in windows. Detects tempo changes, off-beats, and stability.
```bash
python scripts/tempo-detail.py track.mp3
```
**batch-full-analysis.py** — Batch full analysis across a catalog: tempo stability, energy arc, section boundaries, spectral balance. Outputs a comprehensive summary report.
```bash
python scripts/batch-full-analysis.py /path/to/mp3s/
```
#### librosa Notes
- **BPM misreads are genre-dependent and go both directions:**
- Speed metal → reads **half-time** (e.g., reports 99 BPM when felt tempo is ~198 — reads snare on beat 3 as beat 1)
- Doom/sludge → reads **double-time** (e.g., reports 144 BPM when felt tempo is ~72 — counts subdivisions as pulse)
- Power ballads → overcounts (e.g., reports 96 BPM when felt is ~68)
- Heartbeat/pulse tracks → overcounts (e.g., reports 96 when tagged 60)
- **~19% of tracks have significant BPM misreads** in production testing (31-track catalog). Always verify against genre/feel.
- **"Felt BPM"** — the human-perceived tempo vs. librosa's measurement. When a user says "it feels too fast/slow," compare their perception against felt BPM, not librosa BPM. Felt BPM is what matters for playlist sequencing and feedback triage.
- **LLM BPM estimates also diverge** — Gemini AI Studio, Gemini web, and ChatGPT produce different values for the same track. No single source is reliable for BPM; cross-reference at least two.
- Key confidence below 0.5 is low reliability
- Enharmonic equivalents: D# = Eb, C# = Db, A# = Bb, F# = Gb
- librosa is deterministic — same file always produces the same results. Use as ground truth for BPM/key baseline, but always apply genre-aware correction before acting on the number.
- **Slow contemplative songs (felt tempo 70-80 BPM) trigger halftime detection consistently.** librosa raw values around 150-160 BPM with felt tempo around 75-80 BPM is a well-documented pattern. When librosa reports 152 BPM on a song that "feels" much slower than that, the felt tempo is likely half (76). Cross-verify with hi-hat counting before trusting either value.
- **Manual hi-hat counting is the cheap reliable BPM verification** when AI tools disagree. Count hi-hat hits in a 10-second window of a steady-groove section. Most rock/pop songs play hi-hats as straight eighth notes. Calculation: `(hat hits in 10 sec ÷ 2) × 6 = quarter-note BPM`. Example: 25 hi-hat hits in 10 sec → (25 ÷ 2) × 6 = 75 BPM. When sources contest the BPM, this 30-second manual check is the tiebreaker.
### ChatGPT Audio Analysis
ChatGPT can analyze uploaded MP3 files. Key workflow difference from Gemini:
**Blind analysis (recommended first pass):** Upload the MP3 WITHOUT providing the style prompt or any context about what the song should sound like. Ask ChatGPT to describe what it hears — genre, instruments, mood, vocal style, production. This gives unbiased identification of what Suno actually produced.
**Why blind matters:** When LLMs see the style prompt alongside the audio, they tend to hear what the prompt describes rather than what's actually there. In testing, ChatGPT's blind analysis correctly identified "southern rock / blues rock with fingerstyle bass" while Gemini (which saw the style prompt) hallucinated "funk-metal party groove with slap/pop bass" on the same track.
**Calibrated follow-up:** After the blind pass, share the style prompt and ask ChatGPT to compare intent vs. reality. This two-step approach (blind → calibrated) produces the most honest assessment.
**BPM comparison:** ChatGPT's BPM estimates are rough (120-125 range estimates vs. librosa's precise 123.0). Use librosa for BPM, LLMs for subjective qualities.
#### ChatGPT Reliability Warning
**ChatGPT audio analysis is inconsistent across tracks.** In testing:
- On one track (southern rock/blues), blind analysis was accurate — correctly identified genre, instruments, and bass technique where Gemini hallucinated from the style prompt.
- On another track (brass-metal fusion), blind analysis completely failed — described "alternative rock, indie, hip-hop groove, synth pads" with no brass, no metal, and 94 BPM on a 184 BPM track. Did not hear the audio file correctly.
**Possible causes:** Free-tier ChatGPT may not reliably process all audio uploads. Track complexity, length, or encoding may affect analysis quality. More testing is needed to identify when ChatGPT audio analysis can be trusted.
**Recommendation:** Treat ChatGPT audio analysis as a supplementary data point, not a reliable source. Cross-reference with Gemini and librosa. When ChatGPT's blind analysis agrees with librosa's objective measurements, it's likely accurate. When it diverges significantly (wrong genre family, wrong BPM by >30%), discard it. The blind analysis technique remains valuable in principle — just verify the output makes basic sense before acting on it.
### Gemini 3.1 Audio Analysis
### Setup
- Use Google AI Studio (not gemini.google.com) for primary analysis — direct model access, upload audio, control parameters
- Settings: Gemini 3.1 Pro, Thinking: High, **Temperature: 0.5** (see Temperature Findings below), everything else off (no grounding, search, code execution, or structured output)
- Export from Suno as MP3 — sufficient for analysis, WAV offers no practical benefit
- New context per song — prevents bleed between analyses
- gemini.google.com rate limit is separate from AI Studio — alternate between them when daily limits are hit
### Two-Pass Workflow (Mandatory for Fusion Genres)
- Gemini's first pass flattens fusion genres into nearest pure genre (e.g., NOLA brass-metal → "ska-punk", groove-funk-metal → "sludge")
- First pass prompt must include calibration: (a) distinguish tempo changes from volume/intensity dynamics, (b) describe what's actually present without manufacturing genre fusions or instruments that aren't there, (c) verify BPM by tapping the most consistent rhythmic pulse (kick/snare/hi-hat) and reporting the quarter-note pulse, not subdivisions or "felt" impressions
- Second pass (follow-up in same context) is mandatory. Ask specifically about: mood/feel vs. heaviness, volume/intensity dynamics without tempo change, bass techniques and independent role, stereo panning placement
- Before/after improvement is dramatic — example: first pass = "NWOBHM speed metal, zero funk, bass follows guitar." Follow-up = "funk-metal party groove, standout slap bass, Les Claypool comparison." Same audio.
### Prompt Templates
These prompts were refined across 30+ song analyses and consistently produce the most useful output.
#### First Pass — Structured Blind Analysis
Use this for the initial analysis. The blind approach (no style prompt) prevents Gemini from hearing what the prompt describes rather than what's actually there. For cataloging workflows where you want the accuracy comparison in one pass, include the Style Prompt Accuracy section at the end with the style prompt.
```
You are analyzing a track from the band [BAND NAME] for cataloging purposes. Listen to the full track carefully before responding.
IMPORTANT LISTENING NOTES:
- Distinguish between tempo changes (BPM actually shifts) and dynamic changes (volume/intensity shifts without tempo change). A song can get quieter or sparser without slowing down. Report both separately.
- Describe the genre or genres you actually hear without assuming a fusion is present. If the track is in a single sub-genre, name that single sub-genre. If you hear multiple genre influences blended together, name all the ingredients you actually hear — but do NOT manufacture a fusion that isn't present in the audio.
- Only describe instruments and elements you can clearly identify. Do NOT manufacture content that isn't there. If you're uncertain whether something is an actual instrument or a production effect (reverb tails, delay echoes, atmospheric pads, ambient swells), describe what you hear without assigning it to a specific instrument category. Reverb-heavy production can sound similar to brass or strings in places — don't guess.
- For BPM, tap along to the most consistent rhythmic pulse (usually kick/snare or hi-hat). Report the underlying quarter-note pulse, not subdivision rates (don't count eighth notes or sixteenths as the BPM). Do NOT adjust your BPM estimate to fit a "feels fast" or "feels slow" impression — report what your tap-along count actually gives you, then separately note if the song feels different from that count.
Provide your analysis in this exact format:
## Technical
- **Estimated BPM:** [BPM or range if it shifts, note where shifts occur]
- **Estimated Key:** [key/scale]
- **Time Signature:** [detected, note any changes with approximate timestamps]
- **Duration:** [mm:ss]
## Sonic Profile
- **Intro (first 15 seconds):** [exactly what instruments enter, in what order, what they're doing]
- **Overall Genre Feel:** [describe the blend of genre influences you hear — this band fuses multiple styles, so name all the ingredients, not just the dominant one]
- **Guitar:** [tone, style, notable techniques — clean/distorted, riff-driven/atmospheric, any solos and where]
- **Bass:** [how prominent, tone, role — following guitar or independent, any standout moments]
- **Drums:** [style, energy, notable fills or pattern changes, cymbal work]
- **Vocals:** [delivery style, grit level, harmonization, how many voices, any spoken/whispered sections]
- **Other Instruments:** [brass, keys, strings, anything else present]
## Dynamic Arc
Describe how the energy moves through the song from start to finish. Note builds, drops, peaks, and transitions with approximate timestamps. Separately note any volume/intensity shifts that occur WITHOUT tempo changes.
- [0:00-0:xx] — [what's happening]
- [0:xx-0:xx] — [what's happening]
(continue through the full track)
## Outro
- **How it ends:** [fade, hard stop, instrumental tail, final hit — be specific about the last 10 seconds]
## Notable Moments
List 3-5 specific timestamps where something musically interesting happens:
- [timestamp] — [what happens and why it's notable]
## Style Prompt Accuracy
Compare what you hear to what was requested in the generation prompt below. Note:
- What the prompt asked for that IS clearly present in the audio
- What the prompt asked for that is NOT present or only weakly present
- Anything notable in the audio that was NOT in the prompt
Style prompt used: "[PASTE STYLE PROMPT]"
Exclude styles requested: "[PASTE EXCLUDES]"
```
**Blind vs. style-prompted:** For diagnostic workflows (investigating why a song sounds wrong), remove the Style Prompt Accuracy section and style prompt from the first pass entirely. Share the style prompt in a separate follow-up only. For cataloging workflows where you want the comparison in one pass, keep the section as-is.
#### Second Pass — Calibrated Follow-Up (Same Context)
Send this as a follow-up in the same conversation after the first pass:
```
Good analysis. A few areas I'd like you to listen again more carefully for:
1. **Mood/feel vs. heaviness:** How does this track feel — describe what you actually perceive. Heavy instrumentation doesn't predict mood; a heavy song can feel many ways and so can a light one. Don't pick from a suggested list — describe what's there.
2. **Volume/intensity dynamics:** Are there moments where the band gets noticeably quieter or sparser WITHOUT the tempo changing? Describe those shifts.
3. **Bass specifics:** Listen to the bass independently. Is it using any specific techniques — slap/pop, fingerstyle, pick attack, melodic runs independent of guitar? Does it ever take a lead role?
4. **Stereo placement:** Are any instruments panned notably left or right, especially in the intro?
```
#### Non-Band-Specific Variant
For songs not part of a band project (solo work, one-offs), replace the opening line:
```
You are analyzing an AI-generated music track for cataloging purposes. Listen to the full track carefully before responding.
```
And adjust the genre note:
```
- Describe the genre or genres you actually hear without assuming a fusion is present. If the track is in a single sub-genre, name that single sub-genre. If you hear multiple genre influences blended together, name them — but do not manufacture a fusion that isn't present in the audio.
```
### What Gemini Does Well
- Instrument identification (basic rhythm section + lead vocal) — reliably catches guitar, bass, drums, and vocals when they're actually present. Less reliable for non-basic instruments (brass, strings, synths, ambient pads) — see "What Gemini Misses or Gets Wrong."
- Genre classification at macro level — right family even if specific fusion label is wrong (with the caveat that prompting Gemini to "look for fusion" will cause it to manufacture fusions that aren't there)
- Style Prompt Accuracy feedback — the most valuable output. Honest about what Suno delivered vs. what was requested
- Structural timeline with timestamps — dynamic arc breakdowns useful for songbook documentation
- Stereo placement (when asked) — catches hard-panned guitars, center-anchored bass/drums
### What Gemini Misses or Gets Wrong
- Cannot hear fusion when present — rounds to nearest pure genre even after calibration. Needs directed follow-up to surface real fusions
- Misses mood/irony — reads heaviness as aggression by default. Cannot detect playful or ironic energy in heavy music without explicit prompting
- BPM unreliable — may read subdivisions as pulse, or be biased by "feels fast/slow" leading language in prompts. Treat estimates as approximate; verify with manual hi-hat counting when it matters
- Misses volume dynamics on first pass — describes tracks as "consistently dense" when they have significant intensity shifts
- Cannot parse detailed endings — fine detail on last 10 seconds is unreliable
- Misses bass techniques on first pass — slap/pop, melodic runs, lead bass consistently missed until follow-up
- **Hallucinates atmospheric/effect content as instruments** — reverb tails, delay echoes, and ambient pads on guitars/vocals can be misidentified as brass section, string pads, or other instruments that aren't actually present. Atmospheric production framing in the style prompt ("cathedral roominess," "intimate studio room ambience," "wide analog stereo," "shimmer") increases hallucination risk. When Gemini reports a non-basic instrument, verify against the actual audio before filing as fact. **Documented case:** Gemini reported a "very prominent brass section" on a track with no brass at all — what it heard was reverb tails on the vocals and guitars from an atmospheric production stack.
- **Inconsistent key detection vs. librosa** — there is a documented pattern of Gemini consistently leaning toward minor-key readings while librosa consistently leans toward major-key readings on the same track. Without ground truth verification (perfect-pitch listener, manual chord identification), neither source is automatically correct. Use lyric content / emotional context as a tiebreaker, or accept that key is uncertain and document both readings.
- **Sensitive to leading language in prompts** — phrases like "this band plays genre fusion" or "a heavy song can feel upbeat, playful, or ironic" prime Gemini to invent content matching those framings. Use neutral, descriptive prompt language that asks Gemini to describe what it perceives without suggesting what categories to find. The prompt templates in this doc have been progressively de-led to minimize these effects.
### Temperature Findings — 0.5 Outperforms 0.3
A/B testing on the same track (brass-metal fusion) with blind prompts at different temperatures:
**Gemini at 0.5 temp (blind, no style prompt):**
- Genre: "Progressive metal, ska-core, hard rock, swing/jazz, dark cabaret" — best genre description from any LLM
- Brass: Correctly detected on blind prompt (trumpet, trombone, saxophone)
- Dynamics: Verse dropouts well captured — guitars drop out, sparse mix, builds for choruses
- Bass (first pass): "Punchy, metallic, pick-driven, walking lines" — reasonable
- BPM: ~145 (closer to librosa's 184.6 half-time than 0.3 temp's 165)
**Gemini at 0.3 temp (with style prompt + follow-up calibration):**
- Genre: "Manic carnival-punk, ska-core, funk-metal" — decent but needed follow-up to get there
- Brass: Detected but classified as ska-punk rather than NOLA brass-metal
- Bass: Hallucinated "slap/pop funk-metal techniques" — likely influenced by seeing "NOLA funk groove" in the style prompt
- BPM: ~165 (same as a completely different track — unreliable)
**Key takeaway:** 0.5 temp with a blind prompt produced better genre description, more accurate instrument detection, and fewer hallucinations than 0.3 temp with the style prompt provided. The extra temperature gives Gemini room to describe what it actually hears rather than fitting output into narrow categories.
**Persistent gaps at both temperatures:**
- Ending detail remains unreliable — neither caught the a capella moment, vocal yell, triple snare strike, or final brass blast
- Intro accuracy: 0.5 temp said full band at 0:00 when actually brass-only for first 10 seconds
- Follow-up prompts can trigger hallucinations — asking specifically about bass at 0.5 temp produced "slap and pop, lead melodic role" when bass was actually hidden behind guitar/tubas
**Updated recommendation:** Use **0.5 temperature** in AI Studio as the default. Use **blind prompts** (no style prompt) for the first pass. Only share the style prompt in a calibrated follow-up. Be cautious with follow-up questions about specific instruments — they can trigger hallucinations where the first pass was accurate.
### Integration with Feedback Elicitor
- Style Prompt Accuracy as feedback loop: compare what was prompted vs. what Gemini hears → identify what Suno ignores, misinterprets, or adds unbidden → adjust future prompts
- A/B prompt testing: change one variable, generate both, analyze both, compare. Quantifies what prompt changes actually do.
- Batch analysis for playlist ordering: BPM, key, and dynamic arc data across catalog enables data-informed playlist decisions
### Gemini as Suno Prompt Engineering Feedback Loop
The highest-value use of Gemini audio analysis is **real-time A/B testing of Suno prompts during song creation**, not retrospective catalog analysis. Retrospective analysis of already-published songs is limited — you have one audio snapshot per song and no controlled comparison. The real power is testing prompt changes as you make them.
**Recommended workflow for prompt improvement:**
1. Write style prompt + lyrics package
2. Generate 2-3 versions on Suno
3. Run each through Gemini blind at 0.5 temp (NO style prompt in the analysis request)
4. Compare what Gemini hears across versions to what was prompted
5. Identify what the prompt actually controlled vs. what Suno ignored
6. Adjust ONE variable (word position, tag, slider value), regenerate, analyze again
7. Document what moved and what didn't in the songbook generation log
**A/B testing discipline:** Change ONE variable per test. Move "art rock" from position 1 to position 3? Generate both, analyze both, compare. Add "driving technical bass"? Generate with and without, analyze both. This is the only way to systematically learn what Suno actually responds to vs. what it ignores.
**Why Gemini's strengths align with this workflow:** It reliably detects instrument presence, dynamic arc, mood/energy, and stereo placement — exactly the things prompt changes are trying to influence. Its weaknesses (BPM, bass technique, endings) don't matter for A/B comparisons because they'd be equally wrong on both versions.
### Preferred Workflow
Opus 4.6 (Claude) as primary prompter/orchestrator, Gemini 3.1 as audio analysis assistant. Claude builds Suno packages, Gemini analyzes resulting audio, Claude interprets analysis to inform next iteration. Mac can suggest A/B testing as an optional step after presenting a Suno package: "Want to test this prompt? Generate 2-3 versions, run them through Gemini, and I'll tell you what landed and what didn't."
---
## Playlist Sequencing
### Methodology
Playlist ordering requires balancing two dimensions simultaneously:
- **Sonic flow** — BPM transitions, key compatibility (Camelot wheel), energy arcs, timbral variety
- **Lyrical/narrative progression** — thematic arc across songs, emotional journey, story sequencing
Neither dimension alone is sufficient. Adjacent tracks need to work musically AND thematically.
### Sequencing Principles
**Album sequencing fundamentals:**
- Opener must grab attention — front-loading engaging material is critical in the streaming era
- Variety within cohesion — avoid two songs with similar arrangement density, instrumentation, or timbral character back-to-back
- Similar thematic songs need distance — tracks covering the same ground blur when adjacent
- Sonic palette contrast is essential for maintaining interest
- Silence between tracks is itself a sequencing decision — spacing signals mood group shifts
**DJ Harmonic Mixing (Camelot Wheel):**
- Same key (8A→8A): Perfect but monotonous if overused
- +/-1 number, same letter (8A→7A or 9A): Most common professional move, changes one scale note
- Relative major/minor (8A→8B): Mood shift without changing harmonic center. Minor→major lifts; major→minor darkens
- +2 numbers: Energy boost, more noticeable — use sparingly
- Beyond +2: Risk audible clashing — use only for intentional dramatic contrast
**BPM takes priority over key:** A harmonically perfect transition with a 20 BPM jump sounds worse than a minor key clash at the same tempo. Double/half time relationships (70 BPM ↔ 140 BPM) share the same pulse grid and can work together.
**Camelot is a key-relationship tool, not a comprehensive transition-smoothness measure.** The Camelot wheel tracks key relationships only — it does NOT capture:
- **Tempo gaps** — a 152 BPM power-pop banger adjacent to a 78-felt heavy-rock track will sound jarring even with a perfect 10A↔10B relative pair
- **Genre/style register** — power-pop crashing into philosophical-heavy or piano-grief sounds abrupt regardless of Camelot match
- **Energy/dynamic level** — sustained-high banger next to sustained-low melancholy won't blend even with key alignment
- **Production aesthetic** — different mix character (warm analog vs. modern compressed, etc.) creates discontinuity
**When Camelot perfection is misleading:** For genre-outlier tracks (e.g., a power-pop song in a non-power-pop catalog, or a thrash track in a folk-leaning album), Camelot architecture may favor placement spots where the keys line up beautifully but the listening experience is jarring because of tempo, genre, or energy mismatches. Camelot perfection on paper does NOT equal smooth listening transitions when other dimensions diverge.
**Production-confirmed (LV Mirror Image placement, 2026-04-28):** Initial placement options framed Option A as Camelot-strong (three-track perfect run Slide→DID→MI at 9A→10A→10B) and Option C as a "Camelot trade-off for thematic-callback." User correction: *"Not so much trading Camelot because it's honestly just jarring in those other positions and in C it's a little less so."* Options A and B were rejected because the **listening experience was jarring** despite Camelot perfection — the 152 BPM power-pop crashing into 78-felt DID/Cities was a tempo+genre+energy mismatch that Camelot couldn't correct for. Position 3 had rougher Camelot but tonally adjacent tracks (PR 81-felt, Askin'? 92-felt) were less distant from MI's banger energy, producing a less-jarring transition.
**Practical rule for placement evaluation:** When presenting placement options, describe the **listening experience** (smooth / fluid / abrupt / jarring) as the primary criterion. Camelot is one input. Explicitly call out tempo gaps, genre register gaps, and energy gaps alongside Camelot when significant. A Camelot-perfect match with a 70+ BPM gap should be flagged as "Camelot-perfect but tempo-jarring," not as "the strongest option." For genre-outlier tracks, accept that no placement will be Camelot-AND-tempo-AND-genre-perfect — pick where the listening experience is least jarring.
**When Camelot is reliable:** Camelot transitions work cleanly when the songs are also tempo-and-genre-coherent. The framework breaks down specifically when other dimensions diverge. Same-genre / same-tempo-pocket placements (e.g., sequential tracks in a coherent stylistic cluster) benefit straightforwardly from Camelot architecture; cross-genre / cross-tempo placements need additional listening-experience evaluation.
**Concert setlist design (W-Shape model):**
- Featured songs at three peaks (beginning, middle, end) with complementary songs providing changes in key, tempo, timbre, and mood between them
- Multiple peaks and valleys rather than a single arc
- Peak-end rule: audiences remember the best moment and the final moment most vividly
- Encore: a planned 3-5 song mini-set at high energy following a breath-catching break
### Playlist Sequencing Scripts
**playlist-sequencing-data.py** — Generates a full sequencing report: BPM, overall/entry/exit keys, Camelot codes, energy levels, intro/outro energy percentages, and transition quality ratings between adjacent tracks.
```bash
python scripts/playlist-sequencing-data.py /path/to/mp3s/
```
**chord-progression.py** — Analyzes chord changes and key centers in 30-second windows within a single track. Measure-by-measure detection is too noisy with distorted guitars, but 30-second key center summaries are useful.
```bash
python scripts/chord-progression.py track.mp3
```
**Camelot wheel mapping** is embedded in the sequencing script — all 24 keys (12 major, 12 minor) mapped to codes 1A-12A (minor) and 1B-12B (major).

View File

@@ -0,0 +1,34 @@
# Headless Output Contract
```json
{
"feedback_analysis": {
"triage_type": "clear|positive|vague|contradictory|technical",
"identified_dimensions": ["vocals", "energy"],
"confidence": "high|medium|low"
},
"adjustment_recommendations": {
"style_prompt": {"add": [], "remove": [], "reorder_notes": ""},
"exclusions": {"add": [], "remove": []},
"sliders": {"weirdness": "", "style_influence": ""},
"lyrics": {"changes": []},
"model_suggestion": "",
"studio_features": []
},
"confidence_scores": {"style_prompt": "high", "sliders": "medium"},
"iteration_log": {"session_id": "", "round": 1, "tried": [], "user_reaction": "", "reasoning_chain": ""},
"suggested_next_action": {"skill": "", "mode": "", "params": {}}
}
```
## Headless Input Contract
| Flag | Required | Description |
|------|----------|-------------|
| `--feedback` | Yes | Text string or JSON with feedback content |
| `--style-prompt` | Recommended | Original style prompt used for generation |
| `--model` | Optional | Suno model used (v4.5-all, v4 Pro, v4.5 Pro, v4.5+ Pro, v5 Pro, v5.5 Pro) |
| `--sliders` | Optional | JSON with Weirdness/StyleInfluence values |
| `--lyrics` | Optional | File path to original lyrics |
| `--band-profile` | Optional | Profile name for context loading |
| `--iteration-log` | Optional | File path to previous round's iteration log |

View File

@@ -0,0 +1,44 @@
# Feedback Elicitor Output Template
```
## Feedback Summary
{One-paragraph summary of what the user wants changed and why}
## Before/After Preview
**Current sound:** {vivid description of what the current output likely sounds like}
**Target sound:** {vivid description of what the adjusted version should sound like}
## What Changed and Why
{Word-level micro-diff of style prompt: highlight added, removed, and repositioned words with one-line explanations per change. Turns each round into a prompt-engineering micro-lesson.}
## Style Prompt Adjustments
**Current:** {original style prompt if available}
**Recommended:** {modified style prompt}
**Changes:** {bullet list of what changed and why}
**Confidence:** {High -- direct from your feedback / Medium -- interpreted from our conversation / Experimental -- worth trying}
## Exclusion Prompt Adjustments
**Current:** {original exclusions if available}
**Recommended:** {modified exclusions}
## Slider Adjustments
{If applicable -- Weirdness and Style Influence recommendations with reasoning}
## Lyric Adjustments
{If applicable -- specific changes recommended in LT adjustment spec format}
## Studio Features
{If applicable -- recommended Studio workflows}
## Strategy Note
{When applicable: "For this type of issue, try generating 3-5 versions with the adjusted prompt -- Suno's randomness means one may nail it without further changes." Or: "Since only the chorus needs work, consider Replace Section on v5 Pro instead of full regeneration."}
## Additional Notes
{Model suggestions, creative context that influenced recommendations}
```
## Iteration Log
```json
{"session_id": "{timestamp}", "round": 1, "feedback_type": "vague", "dimensions_adjusted": ["vocals", "production"], "key_changes": ["rawer vocals", "less reverb"], "user_intent": "dreamy indie folk", "reasoning_chain": "User said 'too polished' -> mapped to vocal production -> reduced reverb + added raw/intimate descriptors"}
```

View File

@@ -0,0 +1,196 @@
# Playlist Sequencing Methodology
This reference covers album-level playlist sequencing: how to evaluate and order a body of tracks into a coherent listening experience. The focus is on the **album-craft layer** that sits above pairwise transition scoring — narrative structure, energy arcs, key positions, locked arcs, encore design.
For the **transition-evaluation layer** (Camelot wheel rules, BPM tolerances, felt-vs-librosa BPM corrections, listening-experience-as-primary criterion, parallel-key insights), see [`gemini-audio-analysis.md`](./gemini-audio-analysis.md) — particularly the "DJ Harmonic Mixing (Camelot Wheel)" section and the "Felt BPM" subsection. This doc assumes that material as foundational and builds on it.
## When to Use
Apply this methodology when:
- Ordering tracks into a playlist or album for the first time
- Re-evaluating sequencing after a regen wave changes track metrics (BPM, key, energy shape)
- Adding a new track to an existing playlist and choosing its slot
- Diagnosing why a published playlist "doesn't flow" despite individual tracks being strong
Skip the heavy methodology when:
- Reordering 1-2 adjacent tracks with no upstream/downstream impact
- The user has a fixed sequence preference and wants only sonic-transition feedback within it
## Per-Band Playlist YAML — the canonical input
Each band in a project owns exactly one canonical playlist file at `docs/{band-slug}-playlist.yaml`. This file is the **single source of truth** for the band's track sequence and the input to the sequencing script. The schema is straightforward:
```yaml
album: "<Band display name>"
tracks:
- name: "<Song title (matches songbook frontmatter title)>"
file: "<exact filename in docs/audio/, e.g. My Song.mp3>"
# ... one entry per track, in playlist order
```
Multi-band projects keep each band's playlist independent — a band's YAML lives at its own slug and produces its own auto-generated companion + JSON archive. There's no shared global playlist file; that pattern is what causes drift between bands.
If a band exists with songbook entries but no playlist YAML, scaffold one:
```bash
python3 src/skills/suno-band-profile-manager/scripts/scaffold-playlist.py {band-slug} --from-songbook
```
The schema and lifecycle rules (creation on band profile creation, deprecation of the `playlist:` block in band profile YAML, workflow rules on song publish) are documented in `suno-band-profile-manager/references/profile-schema.md` "Per-Band Playlist YAML" section.
## Tools Stack
The methodology is supported by `scripts/playlist-sequencing-data.py` which generates per-track structured data (BPM, overall/entry/exit keys, Camelot codes, energy level, intro/outro energy, transition quality) for every track in a per-band playlist YAML. Output is auto-saved to:
- `docs/audio-analysis/playlists/{band-slug}.json` — raw JSON archive (per-band; does not collide across bands)
- `docs/{band-slug}-playlist-sequencing.md` — refreshed Markdown companion summary (per-band path so each band gets its own; AUTOGEN markers preserve hand-curated content outside)
See the script's `--archive` and `--companion` flags (default ON). Catalog-wide deeper analysis (energy shifts, section boundaries, spectral balance, dynamic character) comes from `scripts/batch-full-analysis.py` writing to `docs/catalog-analysis-report.md`.
The data layer is the *input* to the methodology; it doesn't make sequencing decisions on its own.
## Per-Track Variables to Track
For each track in the playlist, gather and reason about all nine of these. Earlier variables tend to dominate when conflicts arise — but every variable matters and a "perfect score" on one (e.g., Camelot) doesn't override a poor score on another (e.g., tempo).
1. **BPM** (raw librosa) — the measured tempo
2. **Felt BPM** (human-verified) — the *perceived* tempo, often half or double the librosa raw value. **Felt BPM is what governs listening experience**; librosa raw is a measurement that may need halftime/double-time correction. Always verify felt BPM by ear before trusting raw numbers for sequencing decisions. (See `gemini-audio-analysis.md` "Felt BPM" subsection for the correction patterns.)
3. **Overall key + Camelot code** — the dominant key center
4. **Entry key + Camelot code** (first 30 sec) — the key the track *opens* in. May differ from overall.
5. **Exit key + Camelot code** (last 30 sec) — the key the track *ends* in. May differ from overall and from entry.
6. **Energy level** (1-10 scale) — average loudness/intensity. Useful for identifying peaks and valleys.
7. **Intro energy %** — sparse vs. explosive opening. Critical for transition-from-previous-track evaluation.
8. **Outro energy %** — fade vs. hard ending. Critical for transition-into-next-track evaluation.
9. **Dynamic character** — FLAT / MODERATE / DYNAMIC / HIGHLY-DYNAMIC. A "mid-tempo" song with HIGHLY-DYNAMIC character feels very different from a "mid-tempo" song with FLAT character — the listener's experience hinges on this, not just on BPM.
Plus three contextual variables that aren't measurable from audio alone:
10. **Mood/feel** — captured from Listening Notes in the songbook entry, Gemini blind analysis, or the user's articulation.
11. **Sonic palette / arrangement density** — instrumentation profile (acoustic vs. dense metal, brass-led vs. guitar-led, etc.).
12. **Lyrical narrative position** — what the song "means" in the album's story; what came before, what's coming next.
## Transition Discipline
The transition between two adjacent tracks is the actual moment the listener experiences. Per-track variables exist; transitions are *the experience*.
**Exit key matters more than overall key.** A track that's "overall in C minor" but ends in G minor will transition into the next track via G minor, not C minor. Use exit-Camelot of track N → entry-Camelot of track N+1 as the actual transition assessment. The script's `transition_to_next` field already does this.
**Camelot wheel scoring is one input, not the verdict.** See `gemini-audio-analysis.md` "DJ Harmonic Mixing (Camelot Wheel)" for the rules and "Camelot framework limitations" for what it misses. In particular: parallel-key transitions (same root, different mode — e.g., D# major → D# minor) score JARRING on Camelot but are musically a deliberate emotional pivot on the same harmonic center. The listener may hear continuity even when the wheel says discontinuity.
**BPM transition tolerance:** <3% smooth, 3-6% noticeable, >6% requires intentional contrast. Halftime/double-time pairs (e.g., felt 70 and felt 140) share a pulse grid and can mix coherently even though the felt-tempo difference is dramatic — but treat this as a *deliberate* breath-in / breath-out move, not a "smooth" transition.
**Intro/outro % bridges the dynamic side of the transition.** A track ending at 70% energy into a track starting at 15% creates a dramatic drop — fine if it's intentional (act break), jarring if it's mid-act. The 15% intro after a high outro reads as a hush or a reset; the listener's ear interprets the gap.
## Album-Craft Layer
Beyond pairwise transitions, the playlist as a whole has shape. Several established models apply.
### Energy Arc Models
**Inverted-U (classic album):** Tempo and energy build through the front half, peak mid-album, descend toward the close. Valence/arousal (emotional intensity) often *dips* mid-album, creating a journey shape — the energy is high but the emotional weight gets heavier before lifting.
**W-shape (concert / featured-songs model):** Three peaks at the beginning, middle, and end of the playlist, with complementary songs providing variety in key/tempo/timbre/mood between the peaks. Two valleys between the peaks give the listener room to breathe. The W-shape works well when the playlist has clear "anchor" tracks at all three positions.
**Concert peak-end rule:** The audience remembers the best moment and the final moment most vividly. Open higher-than-average, allow a dip, close higher-than-average. The closer doesn't have to be the loudest track — it has to feel like a *resolution*.
A 6-act narrative structure naturally creates a W-shape if Acts I, IV, and VI hold the peaks; valleys land in Acts II and V. But the shape is descriptive, not prescriptive — if the album's emotional logic produces a different curve (front-double-peak with contemplative descent close, for example), name what it actually is rather than forcing the W.
### Key Positions
The methodology treats positions **1, 4, 7, and 10** as load-bearing. Strongest songs go here. Track 1 sets the tone; track 2 confirms the promise (so 1 → 2 cannot be a misfire); track 4 anchors the front; track 7 carries the listener into the middle; track 10 picks up the second half. The final track provides resolution — separate criterion from "strongest song."
For longer playlists (30+ tracks), the same logic extends: 1 / 4 / 7 / 10 / 13 / ... up to a closer that resolves. The pattern thins out past about position 10 because the listener is now inside the album rather than evaluating it from the outside.
**Streaming-era reality:** Front-loading with engaging material is more critical than ever. The first 3-4 tracks determine whether a listener stays with the album or skips. This doesn't mean the front needs to be the *loudest* — it means it needs to be the most *immediately compelling*.
### Sonic Palette Variety
Avoid placing two songs with similar instrumentation, arrangement density, or timbral character next to each other. The methodology's principle: contrast is essential for maintaining interest.
Specific anti-patterns:
- Two intricate intros back-to-back — the listener loses orientation
- Two acoustic stripped-back tracks adjacent — the album feels like it stalled
- Two power-pop bangers adjacent — the genre register collapses into a single mood
- Two slow contemplative tracks adjacent — unless deliberate ("breath section")
Variety is an active design choice, not a side effect of randomization.
### Tempo Variety
Categorize tracks into up-tempo / mid-tempo / slow buckets. Avoid placing too many from the same category adjacent. Two slow songs back-to-back loses listeners unless deliberate.
But: **a deliberate slow-tempo block is a real album convention.** Doom albums, ambient stretches, contemplative interludes — three or four felt-tempo-matched tracks in a row can be an immersive zone if the *sonic palette* and *mood* shift across them. The methodology cautions against accidental same-tempo runs, not against intentional ones.
### Same-Key Adjacency
3-4 songs in the same key consecutively gets boring. When you finally shift keys after too many same-key tracks, the change feels more jarring than a varied stretch would have. Limit same-key consecutive runs to 2 unless you have a specific reason to push to 3.
### Similar-Songs-Need-Distance
Tracks that cover similar **thematic** ground (e.g., two songs about "knowing nothing," two songs about a parent, two songs about NOLA mythology) should be separated in the playlist so each hits fresh. Adjacency blurs them into one long meditation; spacing lets each song carry its own weight.
This is distinct from the same-key rule and the sonic-palette rule — a track can be sonically and harmonically distinct from its neighbor but cover the same lyrical territory.
### Locked Arcs / Preserved Sequences
Sometimes a sequence of 2-N tracks is *deliberately positioned* to read as a unit — a love → loss → grief → healing arc, a three-act story, a musical movement that depends on adjacency. These should be locked: the order within the arc cannot change, and the arc as a whole should travel as a block.
When evaluating playlist changes:
- Surface locked arcs explicitly before proposing reorders
- Treat the arc's position as flexible (the block may move) but the order within as fixed
- If a proposed reorder requires breaking the arc, stop and ask the user — never break a documented locked arc on Mac's own authority
In Lenny's case, the locked arc is the four-song Love & Loss / Heal sequence (From Now Until... → Distant-- → Breast Feeding → The Fire That Never Stops). Per session-context-for-mac.md: *"These are positioned deliberately in the playlist and should not be separated."*
### Encore Structure
For album-as-concert-set framing: Act VI (or the final stretch) functions as a planned 3-5 song mini-set at high energy following a "breath-catching break." The break is often a single contemplative track that gives the listener room before the closing run.
**Anatomy of a working encore section:**
- Breath-catcher: low-mid energy, contemplative or stripped-back
- Encore launch: high-energy banger that re-engages the listener
- Encore middle: sustained energy with thematic coherence
- Encore close / resolution: doesn't have to peak louder; needs to *resolve*
- Optional post-encore coda: the singer alone on the empty stage — fade close
If your final stretch lacks this shape (e.g., averages mid-energy throughout with no clear launch), call it what it is: a "contemplative legacy descent" or "extended fade close" — a different valid shape, not a broken encore.
## What the Methodology Doesn't Capture
**Listening experience is the ultimate arbiter.** Per `gemini-audio-analysis.md`: *"describe the listening experience (smooth / fluid / abrupt / jarring) as the primary criterion. Camelot is one input. Explicitly call out tempo gaps, genre register gaps, and energy gaps alongside Camelot when significant."*
**Parallel-key transitions** (same root, different mode) are musically a deliberate emotional pivot — minor → major lifts; major → minor darkens. Camelot wheel scores them JARRING because the wheel positions are different, but the listener hears the same harmonic center. When evaluating transitions, name parallel-key relationships explicitly when they appear; don't let the JARRING score override what the ear knows.
**Felt-tempo lock vs. raw-BPM lock.** Three tracks at "136 librosa" don't necessarily lock at felt-136 — one of them may be felt-68 with halftime detection. Verify felt BPM before claiming tempo continuity across tracks.
**Genre-outlier placement.** A power-pop track in a swamp-metal album won't have a Camelot-AND-tempo-AND-genre-perfect placement anywhere. Pick where the listening experience is *least jarring*, accept that no slot is ideal, and document the trade-off rather than pretending it's seamless.
**The narrative dimension is non-data.** No script measures whether two adjacent tracks are thematically coherent. That's the user's call (or the orchestrating agent's judgment based on lyrical content + writer voice context). Don't treat the data analysis as sufficient — sonic flow and thematic flow are independent and both must work.
## Process for Reviewing a Playlist
A repeatable approach for "is this playlist sequence working?" — apply variables in this order:
1. **Surface locked arcs** — what cannot move? Document them up front.
2. **Run the script** — get all 38+ tracks' per-track data and per-transition scoring.
3. **Verify felt BPM** for any track with library raw in the 130-180 BPM range or 70-100 BPM range — these are the bands where halftime/double-time confusion is most common. Ask the user when uncertain.
4. **Identify the act structure** — is the playlist organized around narrative acts? What are their thematic functions? How many tracks per act?
5. **Check the energy arc** — what shape does the playlist have? Does it match the intended shape (W, inverted-U, concert peak-end, contemplative descent)?
6. **Check key positions** — do positions 1, 4, 7, 10 have load-bearing tracks? Is the closer a resolution?
7. **Walk transitions act-by-act** — within each act, evaluate transitions on the full variable stack (Camelot, BPM-felt, intro/outro%, sonic palette, theme). Flag the worst.
8. **Identify cluster opportunities** — are felt-tempo cousins scattered when they could be a deliberate immersive block? Are thematic cousins adjacent when they should be separated?
9. **Form a recommendation** — propose specific moves with named justifications across multiple variables. Don't just say "swap X and Y" without naming what each variable says about that swap.
10. **Surface trade-offs honestly** — every move has trade-offs. Name them. Don't claim a move is "cleaner" if it's actually "trades A-jarring for B-jarring."
The output isn't a metrics dump — it's an opinionated proposal grounded in the variables, with explicit acknowledgment of what's locked, what's a judgment call, and where the user's ear should be the tiebreaker.
## Cross-References
- `gemini-audio-analysis.md` — Camelot wheel mechanics, felt-BPM corrections, listening-experience-as-primary criterion (foundational; this doc builds on it)
- `scripts/playlist-sequencing-data.py` — generates the per-track sequencing data
- `scripts/batch-full-analysis.py` — generates the catalog-wide deeper analysis (energy shifts, section boundaries, dynamic character)
- `scripts/audio-deep-analysis.py` — per-song deep analysis
- `docs/audio-analysis/playlists/<album>.json` — JSON archive of the playlist sequencing data
- `docs/audio-analysis/catalog/<date>-deep.json` — JSON archive of the deep catalog analysis
- `docs/playlist-sequencing-data.md` — auto-refreshed Markdown companion to the playlist sequencing JSON
- `docs/catalog-analysis-report.md` — auto-refreshed Markdown companion to the deep catalog analysis
- `docs/audio-analysis-reference.md` — felt-BPM corrections + LLM-comparison hand-curated alongside the auto-table

View File

@@ -0,0 +1,501 @@
# Suno Parameter Map
> **Related references:** For the complete delivery metatag catalog, section tag behavior, and experimental tags, see `suno-lyric-transformer/references/metatag-reference.md`. For section emotional roles and poem-to-song structure decisions, see `suno-lyric-transformer/references/section-jobs.md`.
>
> **Critical zone:** The first ~200 characters of a style prompt carry disproportionate influence on generation. When recommending additions, prioritize the most impactful descriptors for the critical zone. Supplementary descriptors go after.
>
> **Last validated:** April 6, 2026 (Suno v5.5, v5, v4.5-all). Updated with corrected Voices Audio Influence ranges (JG BeatsLab testing), Weirdness-during-Extend drift finding, callback phrasing for Replace Section, Style Influence plateau note. Recommendations are based on these model versions — newer models may respond differently.
Maps feedback dimensions and emotional vocabulary to concrete Suno parameter adjustments.
## Voices & Custom Models
### Voices (User-Uploaded Vocal Identity)
When the user has a Voice active, the Voice provides the vocal identity (timbre, character, tone). Vocal *delivery* adjustments should use **delivery metatags** in the lyrics field, NOT style prompt vocal descriptors.
| Adjustment | Use This (Delivery Metatag) | NOT This (Style Prompt) |
|------------|----------------------------|------------------------|
| Softer delivery | `[Whispered]`, `[Soft]` | "whispered vocals" in style prompt |
| Powerful delivery | `[Belted]`, `[Powerful]` | "powerful singing" in style prompt |
| Emotional delivery | `[Tender]`, `[Yearning]` | "emotional vocals" in style prompt |
| Aggressive delivery | `[Aggressive]`, `[Screamed]` | "aggressive vocal style" in style prompt |
**Audio Influence with Voices — use-case dependent:**
Independent testing (JG BeatsLab, March 2026) found the practical ceiling is lower than Suno's UI range suggests. At 85%, voice resemblance only reached ~70% with increasing shimmer and vocal artifacts. Pushing the slider highest produces worse professional quality, not better.
| Goal | Range | Notes |
|------|-------|-------|
| Voice as subtle flavor | 30-40% | Gentle influence, maximum generation polish |
| Balanced voice + quality | 40-60% | **Recommended starting point** — recognizable with manageable artifacts |
| Identity-focused | 60-70% | Quality trade-off begins here |
| Maximum fidelity (caution) | 70-80% | Diminishing returns; artifacts increase faster than resemblance |
Start at 50% and iterate in 5-10% increments based on feedback. Do not exceed 70% without accepting significant quality trade-offs.
### Custom Models (User-Trained Production Models)
When the user has a Custom Model active, the model has learned a production DNA from its training catalog. Generic production adjustments (e.g., "polished production," "raw mix") may have little effect because the model defaults to its trained production style.
| Feedback | Standard Approach (May Not Work) | Custom Model Approach |
|----------|----------------------------------|-----------------------|
| "Production is too heavy" | "lighter production" | Name the specific element: "reduce distorted guitar layers, more acoustic presence" |
| "Mix sounds wrong" | "better mix" | Target specifics: "push vocals forward, pull back drum room reverb" |
| "Doesn't sound like my style" | Adjust style prompt broadly | Retrain model with better-curated catalog; use more specific prompt overrides |
**Key principle:** Adjustments need to be MORE specific to override a Custom Model's defaults. Generic descriptors get absorbed by the model's learned tendencies.
### Voice + Custom Model Combined
When both a Voice and a Custom Model are active, change **ONE variable at a time** to isolate what moved. Changing the style prompt, Voice delivery metatags, and Audio Influence simultaneously makes it impossible to determine which change caused the result.
**Isolation sequence:**
1. Adjust delivery metatags first (least disruptive — only changes vocal performance)
2. Then adjust Audio Influence if voice fidelity is the issue
3. Then adjust style prompt if the production/arrangement needs changing
4. Regenerate and evaluate after each single change
## v5.5 Workflow Paradigm
v5.5 favors an iterative **generate -> inspect -> section replace -> refine** workflow over full regeneration. This preserves good material and spends fewer credits.
### Recommended v5.5 Workflow
1. **Generate** the initial output from the song package
2. **Inspect** the full result — evaluate structure, melody, emotional angle, and production
3. **Section replace** any sections that need work (preserve sections that are good)
4. **Refine** with targeted adjustments (delivery metatags, slider tweaks, specific prompt edits)
### Critical Checkpoint Questions
Before spending credits on regeneration or further iteration, ask:
- **Is the structure correct?** If yes, do NOT regenerate from scratch — use section replacement.
- **Is the melody usable?** A good melody with flawed production is worth refining. A bad melody needs regeneration.
- **Does the emotional angle justify more credits?** If the song is fundamentally heading in the right direction, refine. If the emotional core is wrong, regenerate.
### When to Use Section Replacement vs. Full Regeneration
| Situation | Recommendation |
|-----------|---------------|
| Structure and melody are good, one section has bad vocals | Section replacement |
| Structure is good, multiple sections need different fixes | Sequential section replacements |
| Melody is wrong throughout | Full regeneration |
| Overall vibe/genre is off | Full regeneration with revised style prompt |
| Good material but wrong emotional direction | Full regeneration — emotional direction is global |
## Style Prompt Mechanics
### Genre Keyword Ordering
Front-loaded terms in the style prompt dominate generation output — the first ~200 characters are the critical zone. When feedback suggests a genre element is too dominant, move that keyword later in the prompt rather than removing it. For secondary influences, use softening formulations like "with [genre] accents" or "[genre] undertones" to reduce their weight without eliminating them.
### Dynamic Arc Mismatch
When the user reports that the ending or energy arc doesn't match their intent, the style prompt likely contains unidirectional language that only describes one direction of movement. The style prompt must describe the full arc.
| Feedback Pattern | Style Prompt Problem | Fix |
|-----------------|---------------------|-----|
| "Too loud at the end" | "crescendo dynamics" or similar build-only language | Replace with "dynamic shifts loud to quiet" |
| "Builds but doesn't resolve" | "build to climax" with no release language | Replace with "slow build then fade" |
| "Ending stays loud despite descent language" | Dynamic descent stated only once | A single mention of descent isn't enough — Suno latches onto the loudest directive. State the arc TWICE: both `building from gentle to crushing then returning to gentle` AND `dynamic arc quiet to massive to quiet` |
| "All one energy level" | No dynamic language at all | Add explicit dynamic descriptors: "dynamic shifts", "quiet verses explosive chorus", etc. |
### Perceived Tempo Control (BPM Tags Are Ineffective)
**BPM tags in lyrics have ZERO detectable effect on Suno's actual output** — confirmed by librosa analysis across 31 songs. Suno picks a single steady tempo per song regardless of any BPM tags. Do not recommend BPM tags in lyrics as a solution for tempo issues.
**v5 alternative:** BPM and key specified in the style prompt (not lyrics) may be more effective in v5: e.g., `"deep house, 122 BPM, A minor, hypnotic groove"`. This is not confirmed as reliable but is worth trying when perceived tempo techniques alone are insufficient.
**"Felt BPM" vs. measured BPM:** When users report tempo issues, their perception reflects felt BPM (human-perceived tempo), not what librosa measures. librosa has genre-specific biases: reads half-time on speed metal (~50% of actual), double-time on doom/sludge (~200% of actual). ~19% of tracks have significant misreads. Always interpret tempo feedback against felt BPM and genre context, not raw librosa numbers.
When the user reports tempo issues, the recommended adjustment path uses perceived tempo techniques:
1. **Word/line density (PRIMARY):** Restructure lyrics — short fragmented lines (1-3 words) for slower perceived delivery, long packed lines with many syllables for faster perceived delivery. This is the most reliable single technique.
2. **Half-time / double-time drum feel:** Add rhythm noun metatags like `[Heavy: halftime]` or `[Double Time]` in the lyrics. Creates perception of halved or doubled tempo without actual BPM change.
3. **Instrumental density / arrangement dropout:** Use `[Energy: stripped, minimal]` to create space that feels slower. Use `[Energy: massive]` for density that feels faster.
4. **Line breaks as breath points:** More line breaks = more pauses = slower perceived delivery. Fewer breaks = longer phrases = faster feel.
5. **Rhythm nouns in style prompt:** "Halftime groove," "double-time driving," "shuffle," "breakbeat" lock feel better than "slow," "fast," or "upbeat."
Try restructuring the lyrics first (techniques 1 and 4) before modifying the style prompt or metatags.
## Descriptor Families as Adjustment Targets
Beyond `[Mood: ...]`, `[Energy: ...]`, `[Vocal Style: ...]`, and `[Instrument: ...]`, these additional descriptor families are available as adjustment targets in the lyrics field:
| Descriptor Family | Examples | Use When Feedback Says |
|-------------------|---------|----------------------|
| `[Atmosphere: ...]` | `[Atmosphere: Dreamy]`, `[Atmosphere: Cyberpunk]`, `[Atmosphere: Medieval]` | "The vibe/setting feels wrong", "needs more atmosphere" |
| `[Texture: ...]` | `[Texture: Grainy]`, `[Texture: Velvet]` | "The sound quality/feel is wrong", "too smooth/rough" |
| `[Effect: ...]` | `[Effect: Lo-fi]`, `[Effect: Reverb: Hall]`, `[Effect: Delay: Ping-pong]`, `[Effect: Distortion]`, `[Effect: Sidechain]`, `[Effect: Radio Filter]` | "Too much/little reverb", "needs effects", "too dry/wet" |
These families provide more targeted control than style prompt descriptors alone. Place them before the section they should affect.
## Parameterized Section Tags
Section tags can include per-section arrangement instructions using colon (`:`) or pipe (`|`) syntax. This enables per-section fixes without changing the overall style prompt.
```
[Verse: whispered vocals, acoustic guitar only]
[Chorus: full band, powerful vocals]
[Bridge: stripped back, piano only]
[Chorus | Half-Time]
[Chorus | Double-Time]
```
| Feedback | Parameterized Section Tag Fix |
|----------|-------------------------------|
| "The verse is too loud/busy" | `[Verse: stripped back, minimal arrangement]` |
| "The chorus doesn't hit hard enough" | `[Chorus: full band, powerful vocals, high energy]` |
| "The bridge needs a different feel" | `[Bridge: acoustic guitar only, intimate]` |
| "The chorus tempo feels wrong" | `[Chorus | Half-Time]` or `[Chorus | Double-Time]` |
This is often more effective than global style prompt changes when the issue is section-specific.
## Inline Performance Modifiers
Parenthetical cues placed after lyric lines control vocal delivery on a per-line basis. Distinct from the backing-vocal parentheses technique — these are performance directions.
```
I can't stop thinking about you (breathy)
HOLD ON (belt)
wait for me... (breath)
stay with me (hold)
```
| Feedback | Inline Modifier |
|----------|----------------|
| "Vocals too forceful on this line" | Add `(breathy)` or `(soft)` after the line |
| "This line needs more power" | Add `(belt)` after the line |
| "Needs a pause/breath feel here" | Add `(breath)` after the line |
| "The note should sustain longer" | Add `(hold)` after the line |
Use sparingly — these are line-level adjustments, not section-level.
## Confirmed Descriptor Effects
These style prompt descriptors have confirmed, predictable effects on Suno output:
| Descriptor | Produces |
|-----------|----------|
| "atmospheric" | Reverb, space, ambient pads |
| "airy" | Reverb/space on vocals |
| "lo-fi warmth" | Vintage character, low-pass filtering |
| "polished radio-ready" | Clean, modern, commercial mix |
| "raw live recording" | Less processed, room sound |
| "driving" | Forward momentum, energetic basslines |
| "lush" | Layered pads, dense production |
| "punchy" | Low-end presence, tight transients |
| "wide stereo" | Spatial separation |
| "gated drums" | 80s-style drum processing |
| "vintage Rhodes" | More specific/effective than "piano" |
Use these as precise adjustment tools when feedback maps to one of these effects.
## Three-Pass Layered Prompting
For complex adjustments that touch multiple dimensions (arrangement, lyrics, and delivery), use a three-pass approach rather than trying to fix everything at once:
1. **Idea pass** — adjust the concept, mood, and genre in the style prompt
2. **Lyric pass** — revise lyrics with structural tags, section tags, and arrangement cues
3. **Performance pass** — add vocal delivery cues (inline modifiers), energy tags, and dynamics metatags
This reduces the chance of conflicting instructions and makes it easier to isolate which change fixed (or broke) something.
## Style Prompt Adjustment Patterns
### Instrumentation & Arrangement
| Feedback | Add to Style Prompt | Add to Exclusions |
|----------|--------------------|--------------------|
| "Too busy/cluttered" | "minimal arrangement, sparse instrumentation" | "no dense layering, no wall of sound" |
| "Too empty/thin" | "lush arrangement, layered instrumentation, full sound" | — |
| "Guitar too loud" | "subtle guitar, background guitar" | "no guitar solo, no heavy guitar" |
| "Needs more guitar" | "prominent guitar, guitar-driven" | — |
| "Too electronic" | "organic, acoustic, live instruments" | "no synthesizer, no electronic beats" |
| "Too acoustic" | "electronic elements, synth textures, modern production" | — |
| "Drums overpower" | "soft percussion, gentle drums, restrained beat" | "no heavy drums, no pounding drums" |
| "Needs more drums" | "driving drums, prominent beat, rhythmic" | — |
| "Second line drums sound like hip-hop" | "second line drums" only produces NOLA parade groove when the surrounding context is up-tempo + energetic + celebratory. Without that context, Suno defaults to hip-hop patterns. Add "New Orleans parade", "celebratory", "up-tempo" to the style prompt. | — |
| "Piano feels wrong" | — | "no piano" (or specify: "no classical piano") |
| "Bass too heavy" | "light bass, subtle low end" | "no heavy bass, no bass drops" |
**Keyword Triggers to Avoid**
Certain style prompt keywords reliably trigger unwanted arrangement choices. When the user reports theatrical, keyboard-heavy, or orchestral results they didn't want, check for these first.
| Keyword | What Suno Produces | Alternative Approach |
|---------|--------------------|---------------------|
| "baroque" | Disney/theatrical arrangements | Describe desired qualities directly; specify instruments by name |
| "rock opera" | Keyboard-heavy, theatrical arrangements | Use "power ballad" for dynamic range without keyboards |
| "cinematic" | Orchestral/soundtrack feel | Specify desired instruments by name (cello, heavy strings, kettle drums) |
| "orchestral" | Light strings/flutes, not the heavy orchestral sound users typically intend | Name the specific orchestral instruments desired (cello, heavy strings, kettle drums) |
### Vocal Direction
| Feedback | Add to Style Prompt | Add to Exclusions |
|----------|--------------------|--------------------|
| "Vocals too polished" | "raw vocal, imperfect delivery, organic phrasing" | "no perfect pitch, no overproduced vocals" |
| "Vocals too rough" | "polished vocal, smooth delivery, clean singing" | "no raspy vocals, no rough vocals" |
| "Voice doesn't match" | Specify: "male/female voice, [age] years old, [tone] delivery" | Exclude the unwanted: "no [gender] vocals" |
| "Too much vibrato" | "steady vocal, straight tone" | "no heavy vibrato" |
| "Vocals too quiet" | "prominent vocals, voice-forward mix" | — |
| "Vocals too loud" | "balanced mix, instrument-forward" | — |
| "Singing sounds robotic" | "natural phrasing, breathy, human vocal" | "no robotic vocals" |
| "Want harmonies" | "vocal harmonies, layered vocals, backing harmonies" | — |
| "Too much harmony" | "solo vocal, single voice, unison" | "no harmonies, no backing vocals" |
### Energy & Tempo
| Feedback | Add to Style Prompt | Slider Adjustment |
|----------|--------------------|--------------------|
| "Too fast" | "halftime groove, laid-back, relaxed groove" (also restructure lyrics: short fragmented lines, more line breaks — see Perceived Tempo Control above). Do NOT add BPM tags — they have no effect. | — |
| "Too slow" | "double-time driving, driving rhythm, energetic pace" (also restructure lyrics: pack more syllables per line, fewer line breaks — see Perceived Tempo Control above). Do NOT add BPM tags — they have no effect. | — |
| "Not energetic enough" | "high energy, powerful, dynamic, driving" | Style Influence ↓ (loosen) |
| "Too intense" | "gentle, soft, understated, subtle" | — |
| "Energy is flat" | "building energy, dynamic shifts, crescendo" | Weirdness ↑ slightly |
| "Feels monotonous" | "dynamic arrangement, shifting textures, varied sections" | Weirdness ↑ |
### Production & Mix
| Feedback | Add to Style Prompt | Slider Adjustment |
|----------|--------------------|--------------------|
| "Too polished" | "lo-fi, raw production, analog warmth, rough edges" | Weirdness ↑ |
| "Too rough/lo-fi" | "radio-ready mix, clean production, crisp, polished" (v5 responds well to production-quality descriptors like "punchy drums, wide stereo field, crisp high-end, warm bass") | Weirdness ↓ |
| "Sounds compressed" | "dynamic range, open mix, breathing room" | — |
| "Too much reverb" | "dry mix, close mic, intimate" | — |
| "Too dry" | "spacious, reverb, ambient, atmospheric" | — |
| "Stereo feels narrow" | "wide stereo field, panoramic, expansive" | — |
| "Sounds dated" | "modern production, contemporary sound, current" | — |
### Mood & Vibe
| Feedback | Add to Style Prompt | Slider Adjustment |
|----------|--------------------|--------------------|
| "Too happy/upbeat" | "melancholic, bittersweet, minor key, moody" | — |
| "Too dark/sad" | "uplifting, bright, major key, hopeful" | — |
| "Too generic" | "distinctive, unique, unconventional" | Weirdness ↑ (65-80) |
| "Too weird" | "familiar, classic, conventional, straightforward" | Weirdness ↓ (25-35) |
| "Not emotional enough" | "emotional, yearning, deeply felt, passionate" | Style Influence ↑ |
| "Too dramatic" | "understated, subtle, restrained, casual" | — |
## Confirmed Suno Behavior
- "NOLA funk swing" lands as syncopation not true swing; "Odd time signatures" consistently ignored in 4/4 rock/metal context
- Suno adds unscripted guitar solos regularly
- Structural/section directions in long style prompts are largely ignored (style prompt sets overall mood, metatags handle sections imperfectly)
## Exclusion Guidance
Prioritize 2-3 specific exclusions over filling the space. Supported syntax: 'no [element]', 'without [element]', 'exclude [element]', 'avoid [element]'. The Exclude Styles field (Pro/Premier only) and in-prompt negatives both function as **probability reduction, not hard bans** — excluded elements may still appear, and regeneration may be needed. Limit to 2-3 most important exclusions; too many destabilizes the arrangement and reduces overall effectiveness.
## Slider Adjustment Guide
### Weirdness (0-100, default 50) — Paid tiers only
| Current Feel | Direction | Target Range | Reasoning |
|-------------|-----------|-------------|-----------|
| Too generic/predictable | ↑ Increase | 60-80 | More unexpected choices |
| Too strange/unfocused | ↓ Decrease | 25-40 | More conventional, familiar |
| Good but could explore | ↑ Slight increase | +10-15 from current | Nudge toward discovery |
**Observations from live testing (not exhaustive — wider range testing needed):**
- Weirdness 50 (default) produced overly steady/predictable results for multi-tempo songs (note: actual BPM does not change — Weirdness affects rhythmic feel and arrangement variation, not tempo)
- Weirdness 60 improved rhythmic variation
- Weirdness 65 produced the best tempo/rhythm variation in testing so far
- Higher values (70+) have not been tested and may produce interesting results for experimental work
- These observations are from v5 Pro with Style Influence 70 — results may differ on other models
- **Sliders don't control tempo, dynamics, or vocal delivery** — they control predictability (Weirdness) and prompt adherence (Style Influence). Don't recommend them as solutions for tempo/vocal issues.
**Confirmed slider combinations by song type (from production use):**
| Song Type | Weirdness | Style Influence | Notes |
|-----------|-----------|-----------------|-------|
| Structured songs (chorus, clear sections) | 50-55 | 75-80 | Higher SI keeps sections well-defined |
| Through-composed with tempo shifts | 55-60 | 70-75 | Slightly looser to allow tempo variation |
| Funk-forward | 60 | 65-70 | Funk needs room to breathe |
| Post-metal / atmospheric | 60-65 | 65 | Balanced exploration with genre grounding |
| Prog with odd time signatures | 65-75 | 65 | High Weirdness helps with non-standard meters |
| Circular / agitated | 75 | 65 | Near the structural ceiling — use [End] tags |
| Acoustic tracks | 40 | 80 | Audio Influence 25%. Persona safe at full AI when style prompt clearly defines non-heavy genre |
| Bass prominence attempts | Any | High SI (85) did not force bass prominence; low Audio Influence (15%) slightly shifted era feel instead | Bass-forward rock/metal remains a Suno limitation |
**Upper limit findings (from live testing):**
- Weirdness 75 is the practical ceiling for structured songs — still experimental but respects section boundaries and [End] tags
- Weirdness 85 causes structural breakdown: [End] tags ignored, songs continue past lyrics with instrumental/gibberish meandering
- At Weirdness 85, coherence loss increases in longer songs — shorter songs or songs with strong repeating structure (chorus anchors) survive higher Weirdness better
- **Recommendation:** Cap at 75 for songs needing structural compliance. Reserve 80+ for jam/experimental mode only.
- Always use [Fade Out] + [End] combo at high Weirdness values — more reliable stop signal than [End] alone
### Audio Influence (0-100%, default 25%) — Persona-dependent
Audio Influence controls how much the loaded Persona's source audio shapes the generation. This parameter should never be omitted from song packages when a Persona is active.
| Scenario | Recommended Range | Notes |
|----------|-------------------|-------|
| Standard tracks | 25% | Default. Reliable baseline for most genres. |
| Acoustic tracks | 25% | Persona is safe at full Audio Influence when style prompt clearly defines a non-heavy genre. |
| Genre-pushing tracks | 20% | Drop 5% when pushing outside the Persona's native genre to give the style prompt more room. |
| Era mismatch (song sounds too modern/dated) | 10-15% | High Audio Influence anchors to the Persona's era. Reduce to let style prompt control era feel. |
**Effective range is 15-25%.** Above 25% shows diminishing returns — the generation doesn't become noticeably more Persona-like, but style prompt influence decreases. Below 15%, the Persona contributes minimal character.
### Style Influence (0-100, default ~50-60) — Paid tiers only
| Current Feel | Direction | Target Range | Reasoning |
|-------------|-----------|-------------|-----------|
| Doesn't match the prompt | ↑ Increase | 65-80 | Tighter adherence to style prompt |
| Too literal/constrained | ↓ Decrease | 25-40 | More creative interpretation |
| Prompt is vague, output is scattered | ↑ Increase + rewrite prompt | 60-70 | Better prompt + tighter adherence |
**Observations from live testing:**
- Style Influence 70 gave enough room for metal weight while staying in the genre lane
- Lower values (45-65) allowed more creative interpretation on bridges and contrasting sections
- These are observations from limited testing, not definitive optimal values
### Per-Section Slider Strategy (v5 Studio)
v5 Studio enables per-section regeneration. Different slider values can be applied to different song sections:
| Section Type | Weirdness | Style Influence | Reasoning |
|-------------|-----------|-----------------|-----------|
| Verse | 35-50 | 55-70 | Stable foundation, moderate adherence |
| Chorus/Hook | 25-40 | 70-85 | Tighter adherence for memorable hooks |
| Bridge | 55-70 | 45-65 | More exploration for contrast |
| Intro/Outro | 40-60 | 50-65 | Balanced — sets/closes the tone |
| Breakdown | 60-80 | 35-55 | Looser interpretation for texture |
## Model-Specific Feedback Patterns
### v4 Pro
- Hard 200-character style prompt limit (silently truncated) — all adjustment text must be extremely concise
- Simpler model — broad genre/mood descriptors work better than nuanced ones
- No slider control, no Persona support
- If feedback requires more nuance than 200 chars allow, suggest upgrading to v4.5+ or higher (1,000-char limit)
### v4.5-all (Free Tier)
- Limited vocal control — voice issues are harder to fix without Persona
- Conversational style prompts work — can be more descriptive in adjustments
- No slider control — all adjustments must go through style prompt and exclusions
- Suggest trying different generation seeds (make again) before changing prompt
### v4.5 Pro / v4.5+ Pro
- Same prompting behavior as v4.5-all but with slider access and Persona support
- Slider adjustments available — use them before expanding the style prompt
- v4.5+ Pro offers advanced creation methods — section-level control improves with this model
- Personas can lock vocal direction more reliably than style prompt alone
### v5 Pro
- Better vocal nuance — vocal adjustments are more likely to work
- Crisp descriptors respond better — keep style prompt adjustments concise
- Section-level editing available — can adjust specific parts without regenerating
- Warp Markers allow fine-grained timing fixes
- If vocals are the only issue, suggest "Replace Section" or "Add Vocals" before full regeneration
## Lyric-to-Metatag Feedback Patterns
| Feedback | Lyric Adjustment |
|----------|-----------------|
| "Chorus doesn't hit hard enough" | Add `[Energy: High]` before chorus, consider `[Build-Up]` section before it |
| "Verse feels wrong" | Check syllable count consistency, add `[Mood: ...]` descriptor |
| "Song structure feels off" | Review section ordering, consider adding/removing Pre-Chorus or Bridge |
| "Vocals change style mid-song" | Add consistent `[Vocal Style: ...]` tags before each section |
| "Instrumental section too long/short" | Adjust `[Intro]`, `[Breakdown]`, or `[Outro]` tag placement and content |
| "Phrasing feels unnatural" | Run syllable counter, normalize line lengths within sections |
## Audio Quality & Artifacts
Common quality issues that cannot be resolved through style prompt changes alone.
| Feedback | Resolution Path |
|----------|----------------|
| "Sounds robotic/glitchy" | Regenerate (try 3-5 times with same prompt); if persistent, simplify style prompt or switch models |
| "Audio quality drops at the end" | Generate shorter (under 2 min), extend carefully; quality degrades in long generations |
| "Weird artifacts/noise" | Regenerate; if persistent, remove problematic descriptors from style prompt |
| "Pronunciation is wrong" | Add phonetic hints in lyrics, or use `[Spoken Word]` metatag for problem lines |
| "Vocals sound auto-tuned" | Add "natural vocal, organic phrasing, imperfect delivery" to style prompt; add "no auto-tune" to exclusions |
| "Clipping/distortion (unwanted)" | Add "clean mix, headroom, dynamic range" to style prompt; reduce layering descriptors |
| "Frequency mud / sounds muffled" | Add "crisp, clear mix, defined frequencies" to style prompt; v5 Remove FX can help |
**External DAW editing (Audacity, etc.) is a one-way operation** — once you edit outside Suno, you lose Suno's editing capabilities on that version. Always keep the original Suno generation as a source of truth.
**Key principle:** Audio quality issues are often generation-specific, not prompt-specific. Always try regenerating 3-5 times before modifying the prompt. Suno's randomness means the same prompt can produce both clean and artifact-heavy outputs.
## Suno Studio Resolution Paths (v5 Pro / Premier)
When feedback maps to Studio features rather than prompt changes.
| Feedback Pattern | Studio Feature | How |
|-----------------|----------------|-----|
| "The timing feels off in the chorus" | Warp Markers | Adjust timing of specific sections without regenerating |
| "Verse 2 vocals are bad but the rest is great" | Replace Section | Regenerate only the problem section, preserving everything else |
| "I want to hear different versions of the chorus" | Alternates | Generate multiple versions of a specific section and compare |
| "Too much reverb/effects on the vocals" | Remove FX | Strip effects from the vocal track |
| "The vocal melody is great but the lyrics are wrong" | Add Vocals | Re-record vocals over the existing instrumental |
| "I need the instrumental without vocals" | Stems | Extract up to 12 stems including isolated instrumental |
| "The song is great but I want to try different words" | Replace Section + Lyrics edit | Change lyrics for specific sections while preserving melody |
### Additional Studio Capabilities (v5.5)
| Feature | What It Does | Key Limitation |
|---------|-------------|----------------|
| Warp Markers | Fix timing post-generation without pitch shift — correct rushed or dragging sections | Timing adjustment only; does not affect pitch or melody. Artifacts with extreme corrections. |
| Remove FX | Strip reverb/delay from the generation for external DAW processing | One-way: stripping FX is for export. May sound thinner — rebuild space with your own reverb in a DAW. |
| Alternates | Generate 2-6 variations of a section, audition in context, comp the best parts | Single-change alternates prevent losing song identity. |
| EQ | 6-band per-track parametric EQ with 11 presets and spectrum analyzer | Start subtle (+/-3dB). Cut > boost for natural results. |
| Remaster | Polish production (Subtle/Normal/High strength) without changing lyrics or structure | Does NOT change style, vocalist, or arrangement — use Cover for those. |
| Heal Edits | Smooth transitions at edit/cut points | Use after rearranging or replacing sections. |
| Time Signature | Grid/metronome alignment for editing | Editing-only — does NOT affect the generative model. Prompt for desired meter instead. |
**Tier mapping:** Legacy Editor features (Replace Section, Extend, Crop, Fade, Rearrange, Stems, Remaster) are available on **Pro and Premier**. Full Studio features (Warp Markers, Remove FX, Alternates, EQ, Heal Edits, Context Window, Recording, MIDI Export) are **Premier only**. Always check the user's tier before recommending.
**For complete Studio & Editor workflows, tips, and troubleshooting:** See [STUDIO-EDITOR-REFERENCE.md](../../suno-agent-band-manager/references/STUDIO-EDITOR-REFERENCE.md).
## Song Length & Pacing
| Feedback | Adjustment |
|----------|-----------|
| "Song is too short" | Use Suno's extend feature; or add sections in lyrics (additional verse, bridge, instrumental break) |
| "Song is too long" | Remove repeated sections in lyrics; trim `[Outro]` content; remove `[Breakdown]` if not essential |
| "Intro goes on too long" | Shorten or remove `[Intro]` lyrics content; add `[Verse 1]` tag earlier; note: `[Intro]` tag is notoriously unreliable |
| "Outro cuts off abruptly" | Add explicit `[Outro]` section with 2-4 lines; add `[Fade Out]` descriptor metatag |
| "Middle section drags" | Add `[Energy: building]` metatags; shorten the dragging section; consider adding a `[Breakdown]` or `[Build-Up]` for variety |
| "Energy drops in extended sections" | Known limitation — 62% of extended tracks drift from original prompt. **Weirdness is strongest during Extend and Bridge generation** — this is the primary drift cause. Keep Weirdness conservative during Extend. Use callback phrasing ("continue same chorus energy") and re-inject genre/mood every 1-2 extends. |
## Genre Drift & Consistency
Genre drift is one of the most common issues — 62% of extended Suno tracks deviate from the original prompt. **The Weirdness slider has the strongest destabilizing effect during Extend and Bridge generation** — high Weirdness during Extend is more disruptive than during initial generation.
| Feedback | Adjustment |
|----------|-----------|
| "Style changed mid-song" | Add consistent genre anchoring via `[Mood: ...]` and `[Energy: ...]` metatags before each section in lyrics |
| "Extended section sounds different" | Regenerate the extension; use v5 Studio Replace Section; keep Weirdness conservative during Extend; use callback phrasing ("continue same chorus energy") and re-inject genre/mood every 1-2 extends |
| "Genre fusion went wrong" | Simplify to single dominant genre; move secondary genre influence to later in style prompt (after critical zone) |
| "Sounds like a different band in the second half" | Add `[Vocal Style: ...]` tags before each section; increase Style Influence slider (65-80) for tighter adherence |
| "Voice/Persona shifted during Replace Section" | Keep Weirdness conservative during Replace operations — high Weirdness can cause Persona/Voice identity shifts |
**Prevention tips:** Front-load genre identity in the first 200 chars of style prompt. Use per-section metatags. Generate 3-5 versions and cherry-pick. For extensions, match the style prompt exactly, keep extensions short (30s-1min increments), and **keep Weirdness lower during Extend than during initial generation**. Use callback phrasing ("continue same chorus energy", "maintain verse mood") to anchor the extension to the existing material.
### Extend Anti-Drift Toolkit
Techniques for maintaining consistency during Extend operations, ordered by effectiveness:
1. **Anchor note restating** — restate genre, mood, key, and instrument palette with each extension in 1-2 sentences. Example: 'Keep the exact current groove, instrument palette, key, and tempo.'
2. **Forbidden element phrasing** — 'No new hooks,' 'No new drums,' 'No new riffs,' 'no risers.' Negative constraints are more effective than positive instruction alone during Extend.
3. **Structural metatag at start** — include `[Chorus]`, `[Bridge]`, `[Outro]` etc. at the beginning of every extension prompt to guide section type.
4. **Energy alignment** — specify energy relative to existing material: 'Bridge energy: 80% of chorus; lower drums...'
5. **Short blocks (30 seconds preferred)** — catch drift before it compounds. Limit to 2-3 extensions maximum per song.
6. **Cover as signal cleaner** — if quality degrades after multiple extensions, use Cover to re-synthesize the audio from scratch, resetting the signal path.
7. **Custom Extend over Quick Extend** — always use Custom Extend for anything you care about. Quick Extend is for rapid prototyping only.
**Verification:** Loop playback at 2x speed to confirm join seams and style consistency.
**Genre-specific outro templates:**
- Gospel/Worship: soft organ and distant choir pad
- Rock/Anthem: final guitar sustain and cymbal swell
- Lo-fi: soft piano motif and vinyl texture
- EDM: filtered synth tail
- Reggae: softening skank guitar
Sources: [Suno 4.5 Plus Extend — Jack Righteous](https://jackrighteous.com/en-us/blogs/guides-using-suno-ai-music-creation/suno-45-plus-extend-tool) | [Outro Prompts — Jack Righteous](https://jackrighteous.com/en-us/blogs/guides-using-suno-ai-music-creation/suno-ai-outro-prompt-guide) | [End Prompts — Jack Righteous](https://jackrighteous.com/en-us/blogs/guides-using-suno-ai-music-creation/suno-ai-end-prompt-guide) | [Fade Out Prompts — Jack Righteous](https://jackrighteous.com/en-us/blogs/guides-using-suno-ai-music-creation/suno-ai-fade-out-prompt-guide)

View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
# ///
"""Batch audio analysis for a song catalog.
Extracts BPM (librosa + aubio), estimated key, and duration for all MP3s
in a directory.
Usage:
python analyze-audio.py [audio-directory] [options]
# Analyze default directory
python analyze-audio.py
# Analyze specific directory
python analyze-audio.py /path/to/audio
# JSON output to file
python analyze-audio.py /path/to/audio --format json -o results.json
Exit codes:
0 = success
1 = invalid arguments or runtime error
2 = missing dependencies
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from audio_deps import require_audio_deps
from companion_writer import update_companion, resolve_companion_path
from json_archiver import resolve_archive_arg, write_archive
SCRIPT_NAME = "analyze-audio"
VERSION = "1.0.0"
def get_key(y, sr):
"""Estimate musical key using chroma features."""
import numpy as np
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
chroma_avg = np.mean(chroma, axis=1)
pitch_classes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
# Major and minor profiles (Krumhansl-Kessler)
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
best_corr = -1
best_key = "Unknown"
for i in range(12):
rolled = np.roll(chroma_avg, -i)
maj_corr = np.corrcoef(rolled, major_profile)[0, 1]
min_corr = np.corrcoef(rolled, minor_profile)[0, 1]
if maj_corr > best_corr:
best_corr = maj_corr
best_key = f"{pitch_classes[i]} major"
if min_corr > best_corr:
best_corr = min_corr
best_key = f"{pitch_classes[i]} minor"
return best_key, best_corr
def get_aubio_bpm(filepath):
"""Get BPM using aubio."""
import numpy as np
try:
from aubio import source, tempo
samplerate = 0
src = source(filepath, samplerate, 512)
samplerate = src.samplerate
t = tempo("default", 1024, 512, samplerate)
beats = []
total_frames = 0
while True:
samples, read = src()
is_beat = t(samples)
if is_beat:
beats.append(t.get_last_s())
total_frames += read
if read < 512:
break
if len(beats) > 1:
intervals = np.diff(beats)
avg_interval = np.median(intervals)
bpm = 60.0 / avg_interval
return round(bpm, 1)
return None
except Exception as e:
return f"error: {e}"
def analyze_file(filepath):
"""Analyze a single audio file."""
import numpy as np
filename = os.path.basename(filepath)
try:
y, sr = librosa.load(filepath, sr=22050)
duration = librosa.get_duration(y=y, sr=sr)
# BPM via librosa
tempo_librosa, _ = librosa.beat.beat_track(y=y, sr=sr)
bpm_librosa = round(float(tempo_librosa[0]) if hasattr(tempo_librosa, '__len__') else float(tempo_librosa), 1)
# BPM via aubio
bpm_aubio = get_aubio_bpm(filepath)
# Key estimation
key, confidence = get_key(y, sr)
mins = int(duration // 60)
secs = int(duration % 60)
return {
'file': filename,
'duration': f"{mins}:{secs:02d}",
'bpm_librosa': bpm_librosa,
'bpm_aubio': bpm_aubio,
'key': key,
'key_confidence': round(confidence, 3),
}
except Exception as e:
return {
'file': filename,
'error': str(e)
}
def format_text_output(results, mp3_count):
"""Format results as human-readable text (original output format)."""
lines = []
lines.append(f"Analyzing {mp3_count} tracks...\n")
lines.append(f"{'Track':<50} {'Duration':>8} {'BPM(lib)':>9} {'BPM(aub)':>9} {'Key':<15} {'Conf':>5}")
lines.append("-" * 100)
for result in results:
if 'error' in result:
lines.append(f"{result['file']:<50} ERROR: {result['error']}")
else:
lines.append(f"{result['file']:<50} {result['duration']:>8} {result['bpm_librosa']:>9} {result['bpm_aubio']:>9} {result['key']:<15} {result['key_confidence']:>5}")
# Summary stats
valid = [r for r in results if 'error' not in r]
if valid:
bpms = [r['bpm_librosa'] for r in valid]
lines.append(f"\n{'='*100}")
lines.append(f"BPM range (librosa): {min(bpms):.0f} - {max(bpms):.0f}")
lines.append(f"Tracks analyzed: {len(valid)}/{mp3_count}")
return "\n".join(lines)
def format_json_output(results, mp3_count):
"""Format results as structured JSON."""
valid = [r for r in results if 'error' not in r]
errors = [r for r in results if 'error' in r]
findings = []
for r in results:
if 'error' in r:
findings.append({
"file": r["file"],
"level": "error",
"message": r["error"],
})
bpms = [r['bpm_librosa'] for r in valid] if valid else []
return {
"script": SCRIPT_NAME,
"version": VERSION,
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass" if not errors else "partial" if valid else "fail",
"metrics": {
"tracks_found": mp3_count,
"tracks_analyzed": len(valid),
"tracks_errored": len(errors),
"bpm_range_librosa": {
"min": min(bpms) if bpms else None,
"max": max(bpms) if bpms else None,
},
"tracks": results,
},
"findings": findings,
"summary": {"total": len(findings)},
}
def main():
require_audio_deps()
import librosa # noqa: E402
import numpy as np # noqa: E402, F401
# Make librosa available to module-level helper functions
globals()["librosa"] = librosa
parser = argparse.ArgumentParser(
description="Batch audio analysis — BPM, key, duration for all MP3s in a directory.",
)
parser.add_argument(
"audio_dir",
nargs="?",
default="docs/audio",
help="Directory containing MP3 files (default: docs/audio)",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
dest="output_format",
help="Output format (default: json)",
)
parser.add_argument(
"-o", "--output",
default=None,
help="Output file path (default: stdout)",
)
parser.add_argument(
"--archive", nargs="?", const="", default="",
help=(
"Persist full JSON output to a dated catalog archive. "
"With no path: writes to docs/audio-analysis/catalog/<YYYY-MM-DD>-summary.json. "
"Pass an explicit path to override. Default: ON."
),
)
parser.add_argument(
"--no-archive", dest="archive", action="store_const", const=None,
help="Skip writing the JSON archive.",
)
parser.add_argument(
"--companion", nargs="?", const="", default="",
help=(
"Refresh the canonical Markdown companion file. "
"With no path: writes to docs/audio-analysis-reference.md. "
"Pass an explicit path to override. Hand-curated sections "
"outside the AUTOGEN markers are preserved. Default: ON."
),
)
parser.add_argument(
"--no-companion", dest="companion", action="store_const", const=None,
help="Skip refreshing the Markdown companion file.",
)
args = parser.parse_args()
audio_dir = args.audio_dir
mp3s = sorted([
os.path.join(audio_dir, f)
for f in os.listdir(audio_dir)
if f.endswith('.mp3')
])
results = []
for filepath in mp3s:
result = analyze_file(filepath)
results.append(result)
json_data = format_json_output(results, len(mp3s))
if args.output_format == "text":
output = format_text_output(results, len(mp3s))
else:
output = json.dumps(json_data, indent=2)
if args.output:
Path(args.output).write_text(output + "\n")
else:
print(output)
# JSON archive (default ON unless --no-archive). Identifier suffix "-summary"
# to distinguish from batch-full-analysis.py's "-deep" archive.
today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + "-summary"
archive_target = resolve_archive_arg("catalog", today, args.archive)
if archive_target is not None:
res = write_archive(archive_target, json_data)
print(f" ARCHIVED: {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
# Companion .md refresh (default ON unless --no-companion). The companion
# docs/audio-analysis-reference.md has hand-curated sections (Felt BPM
# Corrections, LLM BPM Comparison) preserved OUTSIDE the AUTOGEN markers.
# Title + timestamp live inside the markers so each refresh updates them.
companion_target = resolve_companion_path(SCRIPT_NAME, args.companion)
if companion_target is not None:
timestamp = datetime.now(timezone.utc).isoformat()
title_block = (
"# Audio Analysis Reference — Catalog Summary\n"
f"_Generated by `{SCRIPT_NAME}` on {timestamp}_\n"
"_BPM detection: librosa beat_track | Key detection: Krumhansl-Kessler chroma correlation_\n\n"
)
body_lines = format_text_output(results, len(mp3s)).split("\n")
cut = 0
while cut < len(body_lines):
line = body_lines[cut]
if line.startswith("##") or (line.strip() and not line.startswith("#")):
break
cut += 1
md_body = title_block + "\n".join(body_lines[cut:])
res = update_companion(companion_target, SCRIPT_NAME, md_body)
print(f" COMPANION: {res['status']} {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,360 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
# ///
"""Deep audio analysis -- chord progression, energy over time, spectral features,
section boundaries, and harmonic/percussive separation analysis.
Usage:
python audio-deep-analysis.py <audio-file> [options]
# Analyze a single track
python audio-deep-analysis.py track.mp3
# JSON output to file
python audio-deep-analysis.py track.mp3 --format json -o results.json
Exit codes:
0 = success
1 = invalid arguments or runtime error
2 = missing dependencies
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from audio_deps import require_audio_deps
from json_archiver import resolve_archive_arg, write_archive
SCRIPT_NAME = "audio-deep-analysis"
VERSION = "1.0.0"
def format_time(seconds):
m = int(seconds // 60)
s = int(seconds % 60)
frac = int((seconds % 1) * 10)
return f"{m}:{s:02d}.{frac}"
def analyze_chords(y, sr, *, collect=False):
"""Estimate chord/key progression over time using chroma features.
When collect=True, returns data instead of printing.
"""
import numpy as np
pitch_classes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
hop_length = 512
window_seconds = 10
frames_per_window = int(window_seconds * sr / hop_length)
num_windows = chroma.shape[1] // frames_per_window
results = []
if not collect:
print("\n=== KEY/CHORD PROGRESSION ===")
print(f"{'Time':<15} {'Estimated Key':<15} {'Confidence':>10} {'Dominant Notes'}")
print("-" * 65)
for i in range(num_windows):
start_frame = i * frames_per_window
end_frame = (i + 1) * frames_per_window
chunk = chroma[:, start_frame:end_frame]
avg = np.mean(chunk, axis=1)
best_corr = -1
best_key = "Unknown"
for j in range(12):
rolled = np.roll(avg, -j)
maj_corr = np.corrcoef(rolled, major_profile)[0, 1]
min_corr = np.corrcoef(rolled, minor_profile)[0, 1]
if maj_corr > best_corr:
best_corr = maj_corr
best_key = f"{pitch_classes[j]} major"
if min_corr > best_corr:
best_corr = min_corr
best_key = f"{pitch_classes[j]} minor"
top_3 = np.argsort(avg)[-3:][::-1]
dominant = ", ".join([pitch_classes[p] for p in top_3])
start_time = i * window_seconds
end_time = (i + 1) * window_seconds
if collect:
results.append({
"time_start": start_time,
"time_end": end_time,
"key": best_key,
"confidence": round(best_corr, 3),
"dominant_notes": [pitch_classes[p] for p in top_3],
})
else:
print(f"{format_time(start_time)}-{format_time(end_time):<8} {best_key:<15} {best_corr:>10.3f} {dominant}")
return results
def analyze_energy(y, sr, *, collect=False):
"""Show energy/loudness over time.
When collect=True, returns data instead of printing.
"""
import numpy as np
rms = librosa.feature.rms(y=y)[0]
hop_length = 512
window_seconds = 5
frames_per_window = int(window_seconds * sr / hop_length)
max_rms = np.max(rms)
if max_rms == 0:
max_rms = 1
num_windows = len(rms) // frames_per_window
if not collect:
print("\n=== ENERGY / LOUDNESS ARC ===")
print(f"{'Time':<15} {'Energy':>7} {'Bar (visual)'}")
print("-" * 60)
energies = []
windows = []
for i in range(num_windows):
start = i * frames_per_window
end = (i + 1) * frames_per_window
avg = np.mean(rms[start:end])
pct = int((avg / max_rms) * 100)
energies.append(pct)
start_time = i * window_seconds
if collect:
windows.append({
"time": start_time,
"energy_pct": pct,
})
else:
bar = "\u2588" * (pct // 2)
print(f"{format_time(start_time):<15} {pct:>5}% {bar}")
# Detect significant energy shifts
shifts = []
if not collect:
print("\n--- Energy Shifts (>20% change) ---")
found = False
for i in range(1, len(energies)):
diff = energies[i] - energies[i-1]
if abs(diff) > 20:
t = i * window_seconds
direction = "UP" if diff > 0 else "DOWN"
if collect:
shifts.append({
"time": t,
"direction": direction,
"change_pct": abs(diff),
"from_pct": energies[i-1],
"to_pct": energies[i],
})
else:
print(f" {format_time(t)} \u2014 energy {direction} {abs(diff)}% ({energies[i-1]}% \u2192 {energies[i]}%)")
found = True
if not collect and not found:
print(" No dramatic energy shifts detected (all changes < 20%)")
return {"windows": windows, "shifts": shifts}
def analyze_sections(y, sr, *, collect=False):
"""Detect section boundaries using spectral novelty.
When collect=True, returns data instead of printing.
"""
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
bounds = librosa.segment.agglomerative(mfcc, k=8)
bound_times = librosa.frames_to_time(bounds, sr=sr)
results = []
if not collect:
print("\n=== SECTION BOUNDARIES (spectral novelty) ===")
print("Detected section changes at:")
for i, t in enumerate(bound_times):
if t > 0.5: # Skip very start
if collect:
results.append({
"section": i + 1,
"time": round(float(t), 2),
})
else:
print(f" Section {i+1}: {format_time(t)}")
return results
def analyze_spectral_balance(y, sr, *, collect=False):
"""Show low vs mid vs high frequency balance over time."""
import numpy as np
S = np.abs(librosa.stft(y))
freqs = librosa.fft_frequencies(sr=sr)
low_mask = freqs < 250
mid_mask = (freqs >= 250) & (freqs < 2000)
high_mask = freqs >= 2000
window_seconds = 10
hop_length = 512
frames_per_window = int(window_seconds * sr / hop_length)
num_windows = S.shape[1] // frames_per_window
if not collect:
print("\n=== SPECTRAL BALANCE (low/mid/high) ===")
print(f"{'Time':<15} {'Low(<250Hz)':>12} {'Mid(250-2k)':>12} {'High(>2kHz)':>12} {'Balance'}")
print("-" * 70)
results = []
for i in range(num_windows):
start = i * frames_per_window
end = (i + 1) * frames_per_window
chunk = S[:, start:end]
low = np.mean(chunk[low_mask, :])
mid = np.mean(chunk[mid_mask, :])
high = np.mean(chunk[high_mask, :])
total = low + mid + high
if total == 0:
total = 1
l_pct = int(low / total * 100)
m_pct = int(mid / total * 100)
h_pct = int(high / total * 100)
dominant = "BASS-heavy" if l_pct > 45 else "MID-heavy" if m_pct > 50 else "balanced"
start_time = i * window_seconds
if collect:
results.append({
"time": start_time,
"low_pct": l_pct,
"mid_pct": m_pct,
"high_pct": h_pct,
"balance": dominant,
})
else:
print(f"{format_time(start_time):<15} {l_pct:>10}% {m_pct:>10}% {h_pct:>10}% {dominant}")
return results
def format_json_output(filepath, duration, energy_data, chord_data, section_data, spectral_data):
"""Build structured JSON output."""
return {
"script": SCRIPT_NAME,
"version": VERSION,
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"metrics": {
"file": os.path.basename(filepath),
"duration_seconds": round(duration, 2),
"energy_arc": energy_data,
"chord_progression": chord_data,
"section_boundaries": section_data,
"spectral_balance": spectral_data,
},
"findings": [],
"summary": {"total": 0},
}
def main():
require_audio_deps()
import librosa as _librosa # noqa: E402
import numpy as np # noqa: E402, F401
# Make librosa available to module-level helper functions
globals()["librosa"] = _librosa
parser = argparse.ArgumentParser(
description="Deep single-track audio analysis — energy, chords, sections, spectral balance.",
)
parser.add_argument(
"audio_file",
help="Path to the audio file to analyze",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
dest="output_format",
help="Output format (default: json)",
)
parser.add_argument(
"-o", "--output",
default=None,
help="Output file path (default: stdout)",
)
parser.add_argument(
"--archive", nargs="?", const="", default="",
help=(
"Persist full JSON output to a per-song archive. "
"With no path: writes to docs/audio-analysis/songs/<song-slug>.json. "
"Pass an explicit path to override. Default: ON."
),
)
parser.add_argument(
"--no-archive", dest="archive", action="store_const", const=None,
help="Skip writing the JSON archive.",
)
args = parser.parse_args()
filepath = args.audio_file
y, sr = _librosa.load(filepath, sr=22050)
duration = _librosa.get_duration(y=y, sr=sr)
if args.output_format == "text":
print(f"Loading: {os.path.basename(filepath)}")
print(f"Duration: {int(duration//60)}:{int(duration%60):02d}\n")
analyze_energy(y, sr)
analyze_chords(y, sr)
analyze_sections(y, sr)
analyze_spectral_balance(y, sr)
else:
energy_data = analyze_energy(y, sr, collect=True)
chord_data = analyze_chords(y, sr, collect=True)
section_data = analyze_sections(y, sr, collect=True)
spectral_data = analyze_spectral_balance(y, sr, collect=True)
result = format_json_output(filepath, duration, energy_data, chord_data, section_data, spectral_data)
output = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(output + "\n")
else:
print(output)
# Per-song JSON archive (default ON unless --no-archive)
song_slug = os.path.splitext(os.path.basename(filepath))[0]
archive_target = resolve_archive_arg("songs", song_slug, args.archive)
if archive_target is not None:
res = write_archive(archive_target, result)
print(f" ARCHIVED: {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,380 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
# ///
"""
Batch full analysis -- tempo stability, energy arc, section boundaries,
and spectral balance for every track in a catalog directory.
Outputs a summary report in JSON or Markdown text format.
Exit codes:
0 = analysis completed successfully
1 = invalid arguments or no audio files found
2 = missing dependencies (librosa/numpy)
"""
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from audio_deps import require_audio_deps
from companion_writer import update_companion, resolve_companion_path
from json_archiver import resolve_archive_arg, write_archive
SCRIPT_NAME = "batch-full-analysis"
def format_time(seconds):
m = int(seconds // 60)
s = int(seconds % 60)
return f"{m}:{s:02d}"
def analyze_track(filepath):
"""Full analysis of a single track. Returns a dict of results."""
import librosa
import numpy as np
filename = os.path.basename(filepath)
results = {'file': filename}
try:
y, sr = librosa.load(filepath, sr=22050)
duration = librosa.get_duration(y=y, sr=sr)
results['duration'] = duration
# === BPM & TEMPO STABILITY ===
tempo_overall, beats = librosa.beat.beat_track(y=y, sr=sr)
bpm = float(tempo_overall[0]) if hasattr(tempo_overall, '__len__') else float(tempo_overall)
results['bpm'] = round(bpm, 1)
beat_times = librosa.frames_to_time(beats, sr=sr)
if len(beat_times) > 3:
ibis = np.diff(beat_times)
local_bpms = 60.0 / ibis
bpm_std = np.std(local_bpms)
results['bpm_stability'] = "steady" if bpm_std < 5 else "slight variation" if bpm_std < 15 else "TEMPO CHANGES"
results['bpm_range'] = (round(np.percentile(local_bpms, 10), 0), round(np.percentile(local_bpms, 90), 0))
else:
results['bpm_stability'] = "too few beats"
results['bpm_range'] = (0, 0)
# === KEY ===
pitch_classes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
chroma_avg = np.mean(chroma, axis=1)
best_corr = -1
best_key = "Unknown"
for i in range(12):
rolled = np.roll(chroma_avg, -i)
for profile, mode in [(major_profile, "major"), (minor_profile, "minor")]:
corr = np.corrcoef(rolled, profile)[0, 1]
if corr > best_corr:
best_corr = corr
best_key = f"{pitch_classes[i]} {mode}"
results['key'] = best_key
results['key_conf'] = round(best_corr, 3)
# === ENERGY ARC ===
rms = librosa.feature.rms(y=y)[0]
hop_length = 512
max_rms = np.max(rms) if np.max(rms) > 0 else 1
# 5-second windows for energy
window_frames = int(5 * sr / hop_length)
num_windows = len(rms) // window_frames
energies = []
for i in range(num_windows):
avg = np.mean(rms[i*window_frames:(i+1)*window_frames])
pct = int((avg / max_rms) * 100)
energies.append(pct)
results['energy_min'] = min(energies) if energies else 0
results['energy_max'] = max(energies) if energies else 0
results['energy_range'] = results['energy_max'] - results['energy_min']
# Detect significant energy shifts
shifts = []
for i in range(1, len(energies)):
diff = energies[i] - energies[i-1]
if abs(diff) > 20:
t = i * 5
direction = "UP" if diff > 0 else "DOWN"
shifts.append(f"{format_time(t)} {direction} {abs(diff)}%")
results['energy_shifts'] = shifts
results['energy_profile'] = energies
# Classify dynamic character
if results['energy_range'] < 20:
results['dynamic_character'] = "FLAT — minimal dynamics"
elif results['energy_range'] < 40:
results['dynamic_character'] = "MODERATE — some dynamic range"
elif len(shifts) >= 3:
results['dynamic_character'] = "HIGHLY DYNAMIC — big swings"
else:
results['dynamic_character'] = "DYNAMIC — wide range"
# === SPECTRAL BALANCE ===
S = np.abs(librosa.stft(y))
freqs = librosa.fft_frequencies(sr=sr)
low_mask = freqs < 250
mid_mask = (freqs >= 250) & (freqs < 2000)
high_mask = freqs >= 2000
low = np.mean(S[low_mask, :])
mid = np.mean(S[mid_mask, :])
high = np.mean(S[high_mask, :])
total = low + mid + high
if total == 0:
total = 1
results['spectral_low'] = int(low / total * 100)
results['spectral_mid'] = int(mid / total * 100)
results['spectral_high'] = int(high / total * 100)
# === SECTION BOUNDARIES ===
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
n_sections = min(8, max(3, int(duration / 30))) # Scale sections by duration
bounds = librosa.segment.agglomerative(mfcc, k=n_sections)
bound_times = librosa.frames_to_time(bounds, sr=sr)
results['sections'] = [format_time(t) for t in bound_times if t > 0.5]
except Exception as e:
results['error'] = str(e)
return results
def format_json(all_results):
"""Format results as standard module JSON."""
tracks = []
for r in all_results:
if 'error' in r:
tracks.append({
'file': r['file'],
'status': 'error',
'error': r['error'],
})
continue
tracks.append({
'file': r['file'],
'duration': round(r['duration'], 1),
'duration_display': format_time(r['duration']),
'bpm': r['bpm'],
'bpm_stability': r['bpm_stability'],
'bpm_range': list(r['bpm_range']),
'key': r['key'],
'key_confidence': r['key_conf'],
'dynamic_character': r['dynamic_character'],
'energy': {
'min': r['energy_min'],
'max': r['energy_max'],
'range': r['energy_range'],
'shifts': r['energy_shifts'],
'profile': r['energy_profile'],
},
'spectral_balance': {
'low_pct': r['spectral_low'],
'mid_pct': r['spectral_mid'],
'high_pct': r['spectral_high'],
},
'sections': r['sections'],
})
return json.dumps({
'script': 'batch-full-analysis',
'status': 'ok',
'track_count': len(all_results),
'tracks': tracks,
}, indent=2)
def format_text(all_results):
"""Format results as a Markdown report."""
lines = []
lines.append("# Catalog Audio Analysis\n")
lines.append("## Summary Table\n")
lines.append("| Track | Duration | BPM | Stability | Key | Dyn Range | Character |")
lines.append("|-------|----------|-----|-----------|-----|-----------|----------|")
for r in all_results:
if 'error' in r:
continue
dur = format_time(r['duration'])
lines.append(
f"| {r['file'].replace('.mp3','')} | {dur} | {r['bpm']} "
f"| {r['bpm_stability']} | {r['key']} | {r['energy_range']}% "
f"| {r['dynamic_character']} |"
)
lines.append("\n## Energy Shifts (>20% jumps)\n")
for r in all_results:
if 'error' in r or not r.get('energy_shifts'):
continue
lines.append(f"### {r['file'].replace('.mp3','')}")
for shift in r['energy_shifts']:
lines.append(f"- {shift}")
lines.append("")
lines.append("\n## Section Boundaries\n")
lines.append("| Track | Sections |")
lines.append("|-------|----------|")
for r in all_results:
if 'error' in r:
continue
sections = r.get('sections', [])
lines.append(f"| {r['file'].replace('.mp3','')} | {' / '.join(sections)} |")
lines.append("\n## Spectral Balance\n")
lines.append("| Track | Low (<250Hz) | Mid (250-2kHz) | High (>2kHz) |")
lines.append("|-------|-------------|----------------|-------------|")
for r in all_results:
if 'error' in r:
continue
lines.append(
f"| {r['file'].replace('.mp3','')} | {r['spectral_low']}% "
f"| {r['spectral_mid']}% | {r['spectral_high']}% |"
)
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser(
description="Batch audio analysis: tempo, energy, sections, spectral balance."
)
parser.add_argument(
"--audio-dir", default="docs/audio",
help="Directory containing .mp3 files (default: docs/audio)",
)
parser.add_argument(
"--format", choices=["json", "text"], default="json",
help="Output format (default: json)",
)
parser.add_argument(
"-o", "--output",
help="Output file path (default: stdout)",
)
parser.add_argument(
"--archive", nargs="?", const="", default="",
help=(
"Persist full JSON output to a dated catalog archive. "
"With no path: writes to docs/audio-analysis/catalog/<YYYY-MM-DD>-deep.json. "
"Pass an explicit path to override. Default: ON."
),
)
parser.add_argument(
"--no-archive", dest="archive", action="store_const", const=None,
help="Skip writing the JSON archive.",
)
parser.add_argument(
"--companion", nargs="?", const="", default="",
help=(
"Refresh the canonical Markdown companion file. "
"With no path: writes to docs/catalog-analysis-report.md. "
"Pass an explicit path to override. Default: ON."
),
)
parser.add_argument(
"--no-companion", dest="companion", action="store_const", const=None,
help="Skip refreshing the Markdown companion file.",
)
args = parser.parse_args()
require_audio_deps()
import librosa # noqa: F401
import numpy as np # noqa: F401
audio_dir = args.audio_dir
if not os.path.isdir(audio_dir):
print(json.dumps({
"script": "batch-full-analysis",
"status": "fail",
"error": f"Audio directory not found: {audio_dir}",
}), file=sys.stderr)
sys.exit(1)
mp3s = sorted([
os.path.join(audio_dir, f)
for f in os.listdir(audio_dir)
if f.endswith('.mp3')
])
if not mp3s:
print(json.dumps({
"script": "batch-full-analysis",
"status": "fail",
"error": f"No .mp3 files found in: {audio_dir}",
}), file=sys.stderr)
sys.exit(1)
print(f"Analyzing {len(mp3s)} tracks...\n", file=sys.stderr)
all_results = []
for filepath in mp3s:
print(f" Processing: {os.path.basename(filepath)}...", end="", flush=True, file=sys.stderr)
result = analyze_track(filepath)
all_results.append(result)
if 'error' in result:
print(f" ERROR: {result['error']}", file=sys.stderr)
else:
print(f" done ({result['bpm']} BPM, {result['key']}, {result['dynamic_character']})", file=sys.stderr)
# Format output
if args.format == "json":
output = format_json(all_results)
else:
output = format_text(all_results)
# Write output
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"\nReport saved to: {args.output}", file=sys.stderr)
else:
print(output)
# JSON archive (default ON unless --no-archive). Identifier suffix "-deep"
# to distinguish from analyze-audio.py's lighter summary archive.
from datetime import datetime, timezone
today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + "-deep"
archive_target = resolve_archive_arg("catalog", today, args.archive)
if archive_target is not None:
try:
json_data = json.loads(format_json(all_results))
except Exception as exc:
print(f" WARN: archive skipped — JSON build failed: {exc}", file=sys.stderr)
else:
res = write_archive(archive_target, json_data)
print(f" ARCHIVED: {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
# Companion .md refresh (default ON unless --no-companion).
# Title + timestamp live INSIDE the AUTOGEN markers so each refresh
# updates them. Hand-curated sections in the companion file live
# outside the markers and are preserved.
companion_target = resolve_companion_path(SCRIPT_NAME, args.companion)
if companion_target is not None:
timestamp = datetime.now(timezone.utc).isoformat()
title_block = (
"# Catalog Audio Analysis — Full\n"
f"_Generated by `{SCRIPT_NAME}` on {timestamp}_\n\n"
)
body_lines = format_text(all_results).split("\n")
cut = 0
while cut < len(body_lines):
line = body_lines[cut]
if line.startswith("##") or (line.strip() and not line.startswith("#")):
break
cut += 1
md_body = title_block + "\n".join(body_lines[cut:])
res = update_companion(companion_target, SCRIPT_NAME, md_body)
print(f" COMPANION: {res['status']} {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,351 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
# ///
"""Chord/key progression analysis -- shows estimated chords over time
using chroma features with beat-synchronized analysis for cleaner results.
Usage:
python chord-progression.py <audio-file> [options]
# Analyze a single track
python chord-progression.py track.mp3
# JSON output to file
python chord-progression.py track.mp3 --format json -o results.json
Exit codes:
0 = success
1 = invalid arguments or runtime error
2 = missing dependencies
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from audio_deps import require_audio_deps
SCRIPT_NAME = "chord-progression"
VERSION = "1.0.0"
PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
def _build_chord_templates():
"""Build chord templates. Requires numpy, so called after dependency check."""
import numpy as np
templates = {}
for i, note in enumerate(PITCH_CLASSES):
# Major triad: root, major 3rd, perfect 5th
major = np.zeros(12)
major[i] = 1.0
major[(i + 4) % 12] = 0.8
major[(i + 7) % 12] = 0.8
templates[f"{note}"] = major
# Minor triad: root, minor 3rd, perfect 5th
minor = np.zeros(12)
minor[i] = 1.0
minor[(i + 3) % 12] = 0.8
minor[(i + 7) % 12] = 0.8
templates[f"{note}m"] = minor
# Power chord (5th): root, perfect 5th
power = np.zeros(12)
power[i] = 1.0
power[(i + 7) % 12] = 0.9
templates[f"{note}5"] = power
return templates
def match_chord(chroma_vector, chord_templates):
"""Match a chroma vector to the best chord template."""
import numpy as np
best_score = -1
best_chord = "?"
norm = np.linalg.norm(chroma_vector)
if norm < 0.001:
return "silence", 0.0
chroma_norm = chroma_vector / norm
for name, template in chord_templates.items():
t_norm = template / np.linalg.norm(template)
score = np.dot(chroma_norm, t_norm)
if score > best_score:
best_score = score
best_chord = name
return best_chord, best_score
def format_time(seconds):
m = int(seconds // 60)
s = int(seconds % 60)
return f"{m}:{s:02d}"
def analyze_chords_text(filepath, chord_templates):
"""Run chord analysis with text output (original format)."""
import numpy as np
print(f"Loading: {os.path.basename(filepath)}")
y, sr = librosa.load(filepath, sr=22050)
duration = librosa.get_duration(y=y, sr=sr)
print(f"Duration: {format_time(duration)}\n")
# Beat-synchronous chroma for cleaner chord detection
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
beat_times = librosa.frames_to_time(beats, sr=sr)
# Use CQT chroma (better for music)
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
# Aggregate chroma by measures (every 4 beats)
print(f"{'Time':<10} {'Chord':<8} {'Conf':>5} {'Chroma Profile'}")
print("-" * 70)
measure_size = 4 # beats per measure
prev_chord = None
chord_sequence = []
for i in range(0, len(beats) - measure_size, measure_size):
start_frame = beats[i]
end_frame = beats[min(i + measure_size, len(beats) - 1)]
if start_frame >= chroma.shape[1] or end_frame >= chroma.shape[1]:
break
measure_chroma = np.mean(chroma[:, start_frame:end_frame], axis=1)
chord, conf = match_chord(measure_chroma, chord_templates)
start_time = beat_times[i]
# Show top 3 pitch classes
top_3_idx = np.argsort(measure_chroma)[-3:][::-1]
top_3 = [PITCH_CLASSES[p] for p in top_3_idx]
marker = " <<<" if chord != prev_chord and prev_chord is not None else ""
print(f"{format_time(start_time):<10} {chord:<8} {conf:>5.2f} [{', '.join(top_3)}]{marker}")
chord_sequence.append((start_time, chord, conf))
prev_chord = chord
# Summary: chord changes
print(f"\n{'='*50}")
print("CHORD CHANGE SUMMARY")
print("=" * 50)
changes = []
for i in range(1, len(chord_sequence)):
if chord_sequence[i][1] != chord_sequence[i-1][1]:
changes.append((
chord_sequence[i][0],
chord_sequence[i-1][1],
chord_sequence[i][1]
))
if changes:
print(f"{len(changes)} chord changes detected:\n")
for t, from_c, to_c in changes:
print(f" {format_time(t)} \u2014 {from_c} \u2192 {to_c}")
else:
print("No chord changes detected (single chord throughout)")
# Key center summary
print(f"\n{'='*50}")
print("KEY CENTER SUMMARY (by section)")
print("=" * 50)
section_size = 30
num_sections = int(np.ceil(duration / section_size))
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
for s in range(num_sections):
start_sec = s * section_size
end_sec = min((s + 1) * section_size, duration)
start_frame = int(start_sec * sr / 512)
end_frame = int(end_sec * sr / 512)
end_frame = min(end_frame, chroma.shape[1])
if start_frame >= end_frame:
break
section_chroma = np.mean(chroma[:, start_frame:end_frame], axis=1)
best_corr = -1
best_key = "Unknown"
for i in range(12):
rolled = np.roll(section_chroma, -i)
for profile, mode in [(major_profile, "major"), (minor_profile, "minor")]:
corr = np.corrcoef(rolled, profile)[0, 1]
if corr > best_corr:
best_corr = corr
best_key = f"{PITCH_CLASSES[i]} {mode}"
print(f" {format_time(start_sec)}-{format_time(end_sec)}: {best_key} (conf: {best_corr:.3f})")
def analyze_chords_json(filepath, chord_templates):
"""Run chord analysis and return structured data for JSON output."""
import numpy as np
y, sr = librosa.load(filepath, sr=22050)
duration = librosa.get_duration(y=y, sr=sr)
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
beat_times = librosa.frames_to_time(beats, sr=sr)
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
measure_size = 4
prev_chord = None
chord_sequence = []
measures = []
for i in range(0, len(beats) - measure_size, measure_size):
start_frame = beats[i]
end_frame = beats[min(i + measure_size, len(beats) - 1)]
if start_frame >= chroma.shape[1] or end_frame >= chroma.shape[1]:
break
measure_chroma = np.mean(chroma[:, start_frame:end_frame], axis=1)
chord, conf = match_chord(measure_chroma, chord_templates)
start_time = float(beat_times[i])
top_3_idx = np.argsort(measure_chroma)[-3:][::-1]
top_3 = [PITCH_CLASSES[p] for p in top_3_idx]
measures.append({
"time": round(start_time, 2),
"chord": chord,
"confidence": round(float(conf), 3),
"dominant_notes": top_3,
"is_change": chord != prev_chord and prev_chord is not None,
})
chord_sequence.append((start_time, chord, conf))
prev_chord = chord
# Chord changes
transitions = []
for i in range(1, len(chord_sequence)):
if chord_sequence[i][1] != chord_sequence[i-1][1]:
transitions.append({
"time": round(chord_sequence[i][0], 2),
"from": chord_sequence[i-1][1],
"to": chord_sequence[i][1],
})
# Key centers by section
section_size = 30
num_sections = int(np.ceil(duration / section_size))
major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
key_centers = []
for s in range(num_sections):
start_sec = s * section_size
end_sec = min((s + 1) * section_size, duration)
sf = int(start_sec * sr / 512)
ef = min(int(end_sec * sr / 512), chroma.shape[1])
if sf >= ef:
break
section_chroma = np.mean(chroma[:, sf:ef], axis=1)
best_corr = -1
best_key = "Unknown"
for i in range(12):
rolled = np.roll(section_chroma, -i)
for profile, mode in [(major_profile, "major"), (minor_profile, "minor")]:
corr = np.corrcoef(rolled, profile)[0, 1]
if corr > best_corr:
best_corr = corr
best_key = f"{PITCH_CLASSES[i]} {mode}"
key_centers.append({
"time_start": start_sec,
"time_end": round(end_sec, 2),
"key": best_key,
"confidence": round(float(best_corr), 3),
})
tempo_val = float(tempo[0]) if hasattr(tempo, '__len__') else float(tempo)
return {
"script": SCRIPT_NAME,
"version": VERSION,
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"metrics": {
"file": os.path.basename(filepath),
"duration_seconds": round(duration, 2),
"bpm": round(tempo_val, 1),
"total_measures_analyzed": len(measures),
"chord_changes": len(transitions),
"measures": measures,
"transitions": transitions,
"key_centers": key_centers,
},
"findings": [],
"summary": {"total": 0},
}
def main():
require_audio_deps()
import librosa as _librosa # noqa: E402
import numpy as np # noqa: E402, F401
# Make librosa available to module-level helper functions
globals()["librosa"] = _librosa
chord_templates = _build_chord_templates()
parser = argparse.ArgumentParser(
description="Beat-synchronized chord/key progression analysis.",
)
parser.add_argument(
"audio_file",
help="Path to the audio file to analyze",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
dest="output_format",
help="Output format (default: json)",
)
parser.add_argument(
"-o", "--output",
default=None,
help="Output file path (default: stdout)",
)
args = parser.parse_args()
if args.output_format == "text":
analyze_chords_text(args.audio_file, chord_templates)
else:
result = analyze_chords_json(args.audio_file, chord_templates)
output = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(output + "\n")
else:
print(output)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,473 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
Map feedback dimension categories to Suno parameter adjustment recommendations.
Takes structured feedback dimensions (from parse-feedback.py or LLM triage)
and returns baseline parameter adjustment recommendations as structured JSON.
The LLM then refines these recommendations with contextual judgment.
Exit codes:
0 = adjustments generated successfully
1 = invalid input
2 = runtime error
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from suno_constants import CRITICAL_ZONE, EXCLUSION_RECOMMENDED_MAX, PAID_TIERS
# Adjustment lookup tables
# Each dimension maps to a set of possible adjustments categorized by direction
STYLE_PROMPT_ADJUSTMENTS: dict[str, dict[str, dict[str, Any]]] = {
"instrumentation": {
"too_much": {
"add": ["minimal arrangement", "sparse instrumentation", "stripped-back"],
"remove_patterns": ["lush", "layered", "full", "dense", "wall of sound"],
"exclude_add": ["no dense layering"],
},
"too_little": {
"add": ["lush arrangement", "layered instrumentation", "full sound"],
"remove_patterns": ["minimal", "sparse", "stripped"],
"exclude_add": [],
},
"wrong_type": {
"add": [],
"remove_patterns": [],
"exclude_add": [],
"note": "Specify the unwanted instrument in exclusions and desired instrument in style prompt",
},
},
"vocals": {
"too_polished": {
"add": ["raw vocal", "imperfect delivery", "organic phrasing"],
"remove_patterns": ["polished", "clean vocal", "perfect"],
"exclude_add": ["no overproduced vocals"],
},
"too_rough": {
"add": ["polished vocal", "smooth delivery", "clean singing"],
"remove_patterns": ["raw", "rough", "gritty"],
"exclude_add": ["no raspy vocals"],
},
"too_quiet": {
"add": ["prominent vocals", "voice-forward mix"],
"remove_patterns": [],
"exclude_add": [],
},
"too_loud": {
"add": ["balanced mix", "instrument-forward"],
"remove_patterns": ["prominent vocal", "voice-forward"],
"exclude_add": [],
},
"wrong_character": {
"add": [],
"remove_patterns": [],
"exclude_add": [],
"note": "Specify desired vocal character: gender, age, tone, delivery style",
},
},
"energy": {
"too_high": {
"add": ["gentle", "soft", "understated", "subtle"],
"remove_patterns": ["high energy", "powerful", "driving", "intense"],
"exclude_add": [],
"slider": {"weirdness": "unchanged", "style_influence": "unchanged"},
},
"too_low": {
"add": ["high energy", "powerful", "dynamic", "driving"],
"remove_patterns": ["gentle", "soft", "subtle", "laid-back"],
"exclude_add": [],
"slider": {"style_influence": "decrease_slightly"},
},
"flat": {
"add": ["dynamic shifts", "building energy", "crescendo", "varied sections"],
"remove_patterns": [],
"exclude_add": [],
"slider": {"weirdness": "increase_slightly"},
},
},
"tempo": {
"too_fast": {
"add": ["slow tempo", "laid-back", "relaxed groove"],
"remove_patterns": ["uptempo", "fast", "driving rhythm", "energetic pace"],
"exclude_add": [],
},
"too_slow": {
"add": ["uptempo", "driving rhythm", "energetic pace"],
"remove_patterns": ["slow", "laid-back", "relaxed", "gentle pace"],
"exclude_add": [],
},
},
"production": {
"too_polished": {
"add": ["lo-fi", "raw production", "analog warmth", "rough edges"],
"remove_patterns": ["radio-ready", "clean production", "crisp", "polished"],
"exclude_add": [],
"slider": {"weirdness": "increase"},
},
"too_rough": {
"add": ["radio-ready mix", "clean production", "crisp", "polished"],
"remove_patterns": ["lo-fi", "raw", "rough", "analog"],
"exclude_add": [],
"slider": {"weirdness": "decrease"},
},
"too_reverb": {
"add": ["dry mix", "close mic", "intimate"],
"remove_patterns": ["spacious", "reverb", "ambient", "atmospheric"],
"exclude_add": [],
},
"too_dry": {
"add": ["spacious", "reverb", "ambient", "atmospheric"],
"remove_patterns": ["dry", "close mic"],
"exclude_add": [],
},
},
"vibe": {
"too_happy": {
"add": ["melancholic", "bittersweet", "minor key", "moody"],
"remove_patterns": ["uplifting", "bright", "happy", "cheerful", "major key"],
"exclude_add": [],
},
"too_dark": {
"add": ["uplifting", "bright", "major key", "hopeful"],
"remove_patterns": ["melancholic", "dark", "moody", "minor key"],
"exclude_add": [],
},
"too_generic": {
"add": ["distinctive", "unique", "unconventional"],
"remove_patterns": ["classic", "traditional", "conventional"],
"exclude_add": [],
"slider": {"weirdness": "increase_significantly"},
},
"too_weird": {
"add": ["familiar", "classic", "conventional", "straightforward"],
"remove_patterns": ["experimental", "unexpected", "unconventional"],
"exclude_add": [],
"slider": {"weirdness": "decrease_significantly"},
},
},
"music": {
"general_issue": {
"add": [],
"remove_patterns": [],
"exclude_add": [],
"note": "Music feedback requires further narrowing — which aspect of the music? Instrumentation, tempo, energy, production?",
},
},
"structure": {
"needs_bridge": {
"lyric_change": "Add [Bridge] section between second chorus and outro",
},
"chorus_weak": {
"lyric_change": "Add [Energy: High] before chorus, consider [Build-Up] section",
},
"too_long": {
"lyric_change": "Remove repeated sections or shorten verses",
},
"too_short": {
"lyric_change": "Add additional verse or extend instrumental sections",
},
},
"lyrics": {
"phrasing_unnatural": {
"lyric_change": "Run syllable counter, normalize line lengths within sections",
},
"content_mismatch": {
"lyric_change": "Review lyrics against intended mood/theme, revise for alignment",
},
"vocal_style_inconsistent": {
"lyric_change": "Add consistent [Vocal Style: ...] tags before each section",
},
},
"quality": {
"artifacts": {
"note": "Audio artifacts are generation-specific. Regenerate 3-5 times before modifying prompt. If persistent, simplify style prompt.",
},
"robotic_vocals": {
"add": ["natural vocal", "organic phrasing", "human delivery", "breathy"],
"remove_patterns": [],
"exclude_add": ["no auto-tune", "no robotic vocals"],
},
"clipping": {
"add": ["clean mix", "dynamic range", "headroom"],
"remove_patterns": ["heavy", "distorted", "loud", "wall of sound"],
"exclude_add": [],
},
"muffled": {
"add": ["crisp", "clear mix", "defined frequencies", "bright"],
"remove_patterns": ["warm", "lo-fi", "analog"],
"exclude_add": [],
},
},
"length": {
"too_short": {
"lyric_change": "Add sections in lyrics (additional verse, bridge, instrumental break) or use Suno extend feature",
},
"too_long": {
"lyric_change": "Remove repeated sections, trim [Outro] content, remove non-essential [Breakdown]",
},
"intro_too_long": {
"lyric_change": "Shorten or remove [Intro] content, add [Verse 1] tag earlier",
},
"outro_cuts_off": {
"lyric_change": "Add explicit [Outro] section with 2-4 lines, add [Fade Out] metatag",
},
"pacing_drags": {
"lyric_change": "Add [Energy: building] metatags, shorten dragging sections, add [Breakdown] or [Build-Up] for variety",
},
},
}
SLIDER_DIRECTION_MAP = {
"increase_slightly": "+5-10 from current",
"increase": "+15-20 from current",
"increase_significantly": "+25-35 from current (cap at 85)",
"decrease_slightly": "-5-10 from current",
"decrease": "-15-20 from current",
"decrease_significantly": "-25-35 from current (floor at 15)",
"unchanged": "no change recommended",
}
def generate_adjustments(
dimensions: list[dict[str, str]],
current_tier: str = "",
) -> dict[str, Any]:
"""Generate adjustment recommendations from feedback dimensions."""
style_add: list[str] = []
style_remove: list[str] = []
exclude_add: list[str] = []
slider_adjustments: dict[str, str] = {}
lyric_changes: list[str] = []
notes: list[str] = []
for dim_entry in dimensions:
dimension = dim_entry.get("dimension", "")
direction = dim_entry.get("direction", "")
if dimension not in STYLE_PROMPT_ADJUSTMENTS:
notes.append(f"Unknown dimension '{dimension}' — requires LLM judgment")
continue
dim_adjustments = STYLE_PROMPT_ADJUSTMENTS[dimension]
if direction not in dim_adjustments:
available = list(dim_adjustments.keys())
notes.append(
f"Unknown direction '{direction}' for dimension '{dimension}'. "
f"Available: {', '.join(available)}"
)
continue
adj = dim_adjustments[direction]
if "add" in adj:
style_add.extend(adj["add"])
if "remove_patterns" in adj:
style_remove.extend(adj["remove_patterns"])
if "exclude_add" in adj:
exclude_add.extend(adj["exclude_add"])
if "slider" in adj:
for slider_name, slider_dir in adj["slider"].items():
slider_adjustments[slider_name] = SLIDER_DIRECTION_MAP.get(
slider_dir, slider_dir
)
if "lyric_change" in adj:
lyric_changes.append(adj["lyric_change"])
if "note" in adj:
notes.append(adj["note"])
is_paid = current_tier.lower() in PAID_TIERS if current_tier else False
result: dict[str, Any] = {
"style_prompt": {
"add_descriptors": list(dict.fromkeys(style_add)), # dedupe preserving order
"remove_patterns": list(dict.fromkeys(style_remove)),
},
"exclusions": {
"add": list(dict.fromkeys(exclude_add)),
},
}
if slider_adjustments:
if is_paid:
result["sliders"] = slider_adjustments
else:
result["sliders"] = {
"note": "Slider adjustments recommended but not available on free tier. Compensate through style prompt wording.",
"recommended_if_upgraded": slider_adjustments,
}
if lyric_changes:
result["lyrics"] = {"changes": lyric_changes}
if notes:
result["notes"] = notes
consistency_warnings = check_adjustment_consistency(result)
if consistency_warnings:
if "notes" not in result:
result["notes"] = []
result["consistency_warnings"] = consistency_warnings
return result
def check_adjustment_consistency(adjustments: dict[str, Any]) -> list[dict[str, Any]]:
"""Check for internal contradictions in adjustment recommendations."""
warnings = []
style_add = set(adjustments.get("style_prompt", {}).get("add_descriptors", []))
style_remove = set(adjustments.get("style_prompt", {}).get("remove_patterns", []))
exclude_add = set(adjustments.get("exclusions", {}).get("add", []))
# Check for add/remove conflicts
conflicts = style_add & style_remove
if conflicts:
warnings.append({
"type": "add_remove_conflict",
"detail": f"Descriptors appear in both add and remove: {', '.join(conflicts)}",
})
# Check for add/exclude conflicts
for add_desc in style_add:
for excl in exclude_add:
# Simple substring check
if add_desc.lower() in excl.lower() or excl.replace("no ", "").lower() in add_desc.lower():
warnings.append({
"type": "add_exclude_conflict",
"detail": f"Adding '{add_desc}' conflicts with exclusion '{excl}'",
})
# Check style prompt estimated length
total_add_chars = sum(len(d) + 2 for d in style_add) # +2 for ", " separator
if total_add_chars > CRITICAL_ZONE:
warnings.append({
"type": "critical_zone_overflow",
"detail": f"Added descriptors total ~{total_add_chars} chars — prioritize most important for the first {CRITICAL_ZONE} chars of style prompt (critical zone)",
})
# Check exclusion estimated length
total_excl_chars = sum(len(e) + 2 for e in exclude_add)
if total_excl_chars > EXCLUSION_RECOMMENDED_MAX:
warnings.append({
"type": "exclusion_overflow",
"detail": f"Exclusion additions total ~{total_excl_chars} chars — keep total exclusions under ~{EXCLUSION_RECOMMENDED_MAX} chars, prioritize 2-3 most important",
})
return warnings
def main():
parser = argparse.ArgumentParser(
description="Map feedback dimensions to Suno parameter adjustment recommendations.",
epilog="""
Input JSON schema:
Required:
dimensions (array of objects) - Each with:
dimension (string) - Feedback dimension (instrumentation, vocals, energy, tempo, production, vibe, music, structure, lyrics)
direction (string) - Direction of the issue within the dimension
Optional:
tier (string) - User's Suno tier (free, pro, premier) — affects slider recommendations
Dimension/Direction combinations:
instrumentation: too_much, too_little, wrong_type
vocals: too_polished, too_rough, too_quiet, too_loud, wrong_character
energy: too_high, too_low, flat
tempo: too_fast, too_slow
production: too_polished, too_rough, too_reverb, too_dry
vibe: too_happy, too_dark, too_generic, too_weird
music: general_issue
structure: needs_bridge, chorus_weak, too_long, too_short
lyrics: phrasing_unnatural, content_mismatch, vocal_style_inconsistent
Example:
echo '{"dimensions": [{"dimension": "vocals", "direction": "too_polished"}, {"dimension": "energy", "direction": "too_low"}], "tier": "pro"}' | python3 map-adjustments.py --stdin
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
input_group = parser.add_mutually_exclusive_group(required=True)
input_group.add_argument("--input", "-i", help="Path to dimensions JSON file")
input_group.add_argument("--stdin", action="store_true", help="Read JSON from stdin")
parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output to stderr")
args = parser.parse_args()
try:
if args.stdin:
raw = sys.stdin.read()
else:
with open(args.input, "r") as f:
raw = f.read()
data = json.loads(raw)
except (json.JSONDecodeError, FileNotFoundError) as e:
print(json.dumps({
"script": "map-adjustments",
"version": "1.0.0",
"status": "fail",
"findings": [{
"severity": "critical",
"category": "structure",
"issue": str(e),
"fix": "Provide valid JSON input",
}],
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0},
}, indent=2))
sys.exit(1)
if not isinstance(data, dict) or "dimensions" not in data:
print(json.dumps({
"script": "map-adjustments",
"version": "1.0.0",
"status": "fail",
"findings": [{
"severity": "critical",
"category": "structure",
"issue": "Input must be a JSON object with a 'dimensions' array",
"fix": 'Provide {"dimensions": [{"dimension": "...", "direction": "..."}]}',
}],
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0},
}, indent=2))
sys.exit(1)
dimensions = data["dimensions"]
tier = data.get("tier", "")
adjustments = generate_adjustments(dimensions, tier)
result = {
"script": "map-adjustments",
"version": "1.0.0",
"status": "pass",
"adjustments": adjustments,
"input_dimensions": len(dimensions),
"findings": [],
"summary": {"total": 0, "critical": 0, "high": 0, "medium": 0, "low": 0},
}
if args.verbose:
print(f"[map-adjustments] Processed {len(dimensions)} dimensions", file=sys.stderr)
output_json = json.dumps(result, indent=2)
if args.output:
with open(args.output, "w") as f:
f.write(output_json)
else:
print(output_json)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""
Parse and validate structured feedback input for headless mode.
Accepts JSON feedback input and extracts structured dimensions for
the Feedback Elicitor skill. Validates required fields and normalizes
the input structure for downstream processing.
Exit codes:
0 = valid input, structured output returned
1 = validation failed (invalid structure or missing required fields)
2 = runtime error
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from suno_constants import VALID_MODELS
VALID_DIMENSIONS = [
"music",
"vocals",
"energy",
"structure",
"lyrics",
"vibe",
"production",
"tempo",
"instrumentation",
"length",
"quality",
]
VALID_FEEDBACK_TYPES = ["clear", "positive", "vague", "contradictory", "technical"]
def validate_feedback_input(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Validate structured feedback input and return findings."""
findings = []
# feedback_text is required
if "feedback_text" not in data or not data["feedback_text"].strip():
findings.append({
"severity": "critical",
"category": "structure",
"location": {"field": "feedback_text"},
"issue": "Missing or empty feedback_text field",
"fix": "Provide feedback_text with the user's feedback about their Suno generation",
})
# Validate optional fields if present
if "model" in data and data["model"] not in VALID_MODELS:
findings.append({
"severity": "info",
"category": "consistency",
"location": {"field": "model"},
"issue": f"Unrecognized model '{data['model']}' — recommendations may not be model-optimized. Known models: {', '.join(sorted(VALID_MODELS))}",
"fix": "This is informational — the model name will be passed through. Known models receive model-specific recommendations.",
})
if "dimensions" in data:
if not isinstance(data["dimensions"], list):
findings.append({
"severity": "high",
"category": "structure",
"location": {"field": "dimensions"},
"issue": "dimensions must be an array",
"fix": "Provide dimensions as an array of strings",
})
else:
for dim in data["dimensions"]:
if dim not in VALID_DIMENSIONS:
findings.append({
"severity": "low",
"category": "consistency",
"location": {"field": "dimensions", "value": dim},
"issue": f"Unknown dimension '{dim}'. Valid: {', '.join(VALID_DIMENSIONS)}",
"fix": f"Use one of: {', '.join(VALID_DIMENSIONS)}",
})
if "feedback_type" in data and data["feedback_type"] not in VALID_FEEDBACK_TYPES:
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"field": "feedback_type"},
"issue": f"Unknown feedback_type '{data['feedback_type']}'. Valid: {', '.join(VALID_FEEDBACK_TYPES)}",
"fix": f"Use one of: {', '.join(VALID_FEEDBACK_TYPES)}",
})
if "slider_settings" in data:
sliders = data["slider_settings"]
if not isinstance(sliders, dict):
findings.append({
"severity": "medium",
"category": "structure",
"location": {"field": "slider_settings"},
"issue": "slider_settings must be an object",
"fix": "Provide as {\"weirdness\": 50, \"style_influence\": 50}",
})
else:
for key in ["weirdness", "style_influence"]:
if key in sliders:
val = sliders[key]
if not isinstance(val, (int, float)) or val < 0 or val > 100:
findings.append({
"severity": "medium",
"category": "consistency",
"location": {"field": f"slider_settings.{key}"},
"issue": f"{key} must be a number between 0 and 100",
"fix": f"Set {key} to a value between 0 and 100",
})
return findings
def extract_structured_output(data: dict[str, Any]) -> dict[str, Any]:
"""Extract and normalize structured feedback for downstream processing."""
output = {
"feedback_text": data.get("feedback_text", "").strip(),
"context": {
"original_style_prompt": data.get("original_style_prompt", ""),
"original_lyrics": data.get("original_lyrics", ""),
"band_profile": data.get("band_profile", ""),
"model": data.get("model", ""),
"slider_settings": data.get("slider_settings", {}),
"intent": data.get("intent", ""),
},
"pre_categorized": {
"feedback_type": data.get("feedback_type", ""),
"dimensions": data.get("dimensions", []),
},
}
# Strip empty context fields
output["context"] = {k: v for k, v in output["context"].items() if v}
output["pre_categorized"] = {k: v for k, v in output["pre_categorized"].items() if v}
return output
def main():
parser = argparse.ArgumentParser(
description="Parse and validate structured feedback input for Suno Feedback Elicitor headless mode.",
epilog="""
Input JSON schema:
Required:
feedback_text (string) - The user's feedback about their Suno generation
Optional context:
original_style_prompt (string) - Style prompt used for generation
original_lyrics (string) - Lyrics used for generation
band_profile (string) - Band profile name used
model (string) - Suno model used (v4.5-all, v4 Pro, v4.5 Pro, v4.5+ Pro, v5 Pro)
slider_settings (object) - {weirdness: 0-100, style_influence: 0-100}
intent (string) - What the user was going for
Optional pre-categorization:
feedback_type (string) - clear, positive, vague, contradictory
dimensions (array) - Problem dimensions: music, vocals, energy, structure, lyrics, vibe, production, tempo, instrumentation
Example:
echo '{"feedback_text": "The guitar is too loud", "model": "v5 Pro"}' | python3 parse-feedback.py --stdin
python3 parse-feedback.py --input feedback.json
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
input_group = parser.add_mutually_exclusive_group(required=True)
input_group.add_argument("--input", "-i", help="Path to feedback JSON file")
input_group.add_argument("--stdin", action="store_true", help="Read JSON from stdin")
parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output to stderr")
args = parser.parse_args()
try:
if args.stdin:
raw = sys.stdin.read()
else:
with open(args.input, "r") as f:
raw = f.read()
data = json.loads(raw)
except json.JSONDecodeError as e:
result = {
"script": "parse-feedback",
"version": "1.0.0",
"status": "fail",
"findings": [{
"severity": "critical",
"category": "structure",
"location": {"field": "root"},
"issue": f"Invalid JSON: {e}",
"fix": "Provide valid JSON input",
}],
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0},
}
output_json = json.dumps(result, indent=2)
if args.output:
with open(args.output, "w") as f:
f.write(output_json)
else:
print(output_json)
sys.exit(1)
except FileNotFoundError:
print(json.dumps({
"script": "parse-feedback",
"version": "1.0.0",
"status": "fail",
"findings": [{
"severity": "critical",
"category": "structure",
"location": {"field": "input"},
"issue": f"File not found: {args.input}",
"fix": "Provide a valid file path",
}],
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0},
}, indent=2))
sys.exit(1)
if not isinstance(data, dict):
result = {
"script": "parse-feedback",
"version": "1.0.0",
"status": "fail",
"findings": [{
"severity": "critical",
"category": "structure",
"location": {"field": "root"},
"issue": "Input must be a JSON object",
"fix": "Provide a JSON object with at least a feedback_text field",
}],
"summary": {"total": 1, "critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0},
}
output_json = json.dumps(result, indent=2)
if args.output:
with open(args.output, "w") as f:
f.write(output_json)
else:
print(output_json)
sys.exit(1)
findings = validate_feedback_input(data)
has_critical = any(f["severity"] == "critical" for f in findings)
has_high = any(f["severity"] == "high" for f in findings)
has_actionable = any(f["severity"] in ("critical", "high", "medium", "low") for f in findings)
if has_critical or has_high:
status = "fail"
elif has_actionable:
status = "warning"
else:
status = "pass"
structured_output = extract_structured_output(data) if not has_critical else None
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
for f in findings:
sev = f["severity"]
if sev in severity_counts:
severity_counts[sev] += 1
result = {
"script": "parse-feedback",
"version": "1.0.0",
"status": status,
"findings": findings,
"summary": {
"total": len(findings),
**severity_counts,
},
}
if structured_output:
result["parsed"] = structured_output
if args.verbose:
print(f"[parse-feedback] Status: {status}, Findings: {len(findings)}", file=sys.stderr)
output_json = json.dumps(result, indent=2)
if args.output:
with open(args.output, "w") as f:
f.write(output_json)
if args.verbose:
print(f"[parse-feedback] Output written to {args.output}", file=sys.stderr)
else:
print(output_json)
sys.exit(0 if status in ("pass", "warning") else 1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,452 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["librosa>=0.10", "numpy>=1.24", "pyyaml>=6.0"]
# ///
"""
Generate playlist sequencing data: Camelot codes, entry/exit keys,
energy levels, and transition compatibility for an audio catalog.
When given a --playlist YAML config, uses the specified track order and
album name. Without a config, auto-discovers all .mp3 files in the
audio directory (sorted alphabetically).
Exit codes:
0 = analysis completed successfully
1 = invalid arguments or no audio files found
2 = missing dependencies (librosa/numpy)
"""
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "_shared"))
from audio_deps import require_audio_deps
from companion_writer import update_companion, resolve_companion_path
from json_archiver import resolve_archive_arg, write_archive
SCRIPT_NAME = "playlist-sequencing-data"
PITCH_CLASSES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
# Camelot wheel mapping
CAMELOT = {
'C major': '8B', 'A minor': '8A',
'G major': '9B', 'E minor': '9A',
'D major': '10B', 'B minor': '10A',
'A major': '11B', 'F# minor': '11A',
'E major': '12B', 'C# minor': '12A',
'B major': '1B', 'G# minor': '1A',
'F# major': '2B', 'D# minor': '2A',
'C# major': '3B', 'A# minor': '3A',
'G# major': '4B', 'F minor': '4A',
'D# major': '5B', 'C minor': '5A',
'A# major': '6B', 'G minor': '6A',
'F major': '7B', 'D minor': '7A',
# Enharmonic equivalents
'Db major': '3B', 'Bb minor': '3A',
'Ab major': '4B', 'Eb minor': '2A',
'Eb major': '5B', 'Bb major': '6B',
'Gb major': '2B',
}
def detect_key(chroma_segment):
"""Detect key from a chroma segment."""
import numpy as np
MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
avg = np.mean(chroma_segment, axis=1)
best_corr = -1
best_key = "Unknown"
for i in range(12):
rolled = np.roll(avg, -i)
for profile, mode in [(MAJOR_PROFILE, "major"), (MINOR_PROFILE, "minor")]:
corr = np.corrcoef(rolled, profile)[0, 1]
if corr > best_corr:
best_corr = corr
best_key = f"{PITCH_CLASSES[i]} {mode}"
return best_key, best_corr
def get_camelot(key):
"""Convert key name to Camelot code."""
return CAMELOT.get(key, "??")
def camelot_distance(code1, code2):
"""Calculate distance on Camelot wheel. 0=same, 1=adjacent, etc."""
if code1 == "??" or code2 == "??":
return -1
num1, letter1 = int(code1[:-1]), code1[-1]
num2, letter2 = int(code2[:-1]), code2[-1]
# Same position
if code1 == code2:
return 0
# Relative major/minor (same number, different letter)
if num1 == num2:
return 0.5
# Adjacent numbers, same letter
num_dist = min(abs(num1 - num2), 12 - abs(num1 - num2))
if letter1 == letter2 and num_dist == 1:
return 1
if letter1 == letter2 and num_dist == 2:
return 2
# Different letter + different number
return num_dist + 0.5
def format_time(seconds):
return f"{int(seconds//60)}:{int(seconds%60):02d}"
def analyze_track(filepath):
"""Extract sequencing data for a single track."""
import librosa
import numpy as np
y, sr = librosa.load(filepath, sr=22050)
duration = librosa.get_duration(y=y, sr=sr)
# Overall key
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
overall_key, overall_conf = detect_key(chroma)
# Entry key (first 30 seconds)
entry_frames = int(30 * sr / 512)
entry_key, entry_conf = detect_key(chroma[:, :min(entry_frames, chroma.shape[1])])
# Exit key (last 30 seconds)
exit_start = max(0, chroma.shape[1] - entry_frames)
exit_key, exit_conf = detect_key(chroma[:, exit_start:])
# BPM
tempo, beats = librosa.beat.beat_track(y=y, sr=sr)
bpm = float(tempo[0]) if hasattr(tempo, '__len__') else float(tempo)
# Energy level (normalize to 1-10 scale)
rms = librosa.feature.rms(y=y)[0]
avg_energy = np.mean(rms)
max_possible = np.max(rms) * 1.2 # leave headroom
energy_pct = avg_energy / max_possible if max_possible > 0 else 0
energy_level = max(1, min(10, int(energy_pct * 10) + 3)) # offset for rock/metal bias
# Intro energy (first 15 sec)
intro_frames = int(15 * sr / 512)
intro_energy = np.mean(rms[:min(intro_frames, len(rms))])
intro_pct = intro_energy / (np.max(rms) if np.max(rms) > 0 else 1) * 100
# Outro energy (last 15 sec)
outro_start = max(0, len(rms) - intro_frames)
outro_energy = np.mean(rms[outro_start:])
outro_pct = outro_energy / (np.max(rms) if np.max(rms) > 0 else 1) * 100
return {
'duration': duration,
'bpm': round(bpm, 1),
'overall_key': overall_key,
'overall_conf': round(overall_conf, 3),
'overall_camelot': get_camelot(overall_key),
'entry_key': entry_key,
'entry_conf': round(entry_conf, 3),
'entry_camelot': get_camelot(entry_key),
'exit_key': exit_key,
'exit_conf': round(exit_conf, 3),
'exit_camelot': get_camelot(exit_key),
'energy_level': energy_level,
'intro_energy_pct': round(intro_pct),
'outro_energy_pct': round(outro_pct),
}
def load_playlist(playlist_path):
"""Load playlist config from a YAML file. Returns (album_name, track_list)."""
import yaml
with open(playlist_path, 'r') as f:
config = yaml.safe_load(f)
album = config.get('album', 'Audio Analysis')
tracks = [
(t['name'], t['file'])
for t in config.get('tracks', [])
]
return album, tracks
def discover_tracks(audio_dir):
"""Auto-discover .mp3 files in a directory. Returns (album_name, track_list)."""
mp3s = sorted(f for f in os.listdir(audio_dir) if f.endswith('.mp3'))
tracks = [
(os.path.splitext(f)[0], f)
for f in mp3s
]
return "Audio Analysis", tracks
def format_json(album_name, results):
"""Format results as standard module JSON."""
tracks = []
for i, r in enumerate(results):
if 'error' in r:
tracks.append({
'position': i + 1,
'name': r['name'],
'status': 'error',
'error': r['error'],
})
continue
entry = {
'position': i + 1,
'name': r['name'],
'duration': round(r['duration'], 1),
'duration_display': format_time(r['duration']),
'bpm': r['bpm'],
'key': {
'overall': r['overall_key'],
'overall_confidence': r['overall_conf'],
'overall_camelot': r['overall_camelot'],
'entry': r['entry_key'],
'entry_confidence': r['entry_conf'],
'entry_camelot': r['entry_camelot'],
'exit': r['exit_key'],
'exit_confidence': r['exit_conf'],
'exit_camelot': r['exit_camelot'],
},
'energy': {
'level': r['energy_level'],
'intro_pct': r['intro_energy_pct'],
'outro_pct': r['outro_energy_pct'],
},
}
# Add transition data if available
if 'transition' in r:
entry['transition_to_next'] = r['transition']
tracks.append(entry)
return json.dumps({
'script': 'playlist-sequencing-data',
'status': 'ok',
'album': album_name,
'track_count': len(results),
'tracks': tracks,
}, indent=2)
def format_text(album_name, results):
"""Format results as a Markdown report."""
lines = []
lines.append(f"# {album_name} -- Playlist Sequencing Data")
lines.append("# Generated via librosa analysis + Camelot wheel mapping\n")
lines.append("## Track Data (Playlist Order)\n")
lines.append("| # | Track | BPM | Key | Camelot | Entry Key | Exit Key | Energy | Intro% | Outro% |")
lines.append("|---|-------|-----|-----|---------|-----------|----------|--------|--------|--------|")
for i, r in enumerate(results):
if 'error' in r:
continue
lines.append(
f"| {i+1} | {r['name']} | {r['bpm']} | {r['overall_key']} "
f"| {r['overall_camelot']} | {r['entry_key']} ({r['entry_camelot']}) "
f"| {r['exit_key']} ({r['exit_camelot']}) | {r['energy_level']} "
f"| {r['intro_energy_pct']}% | {r['outro_energy_pct']}% |"
)
lines.append("\n## Transition Analysis\n")
lines.append("| From | To | Key Distance | BPM Change | Quality |")
lines.append("|------|----|-------------|------------|---------|")
for i in range(len(results) - 1):
if 'error' in results[i] or 'error' in results[i+1]:
continue
r = results[i]
n = results[i+1]
cam_dist = camelot_distance(r['exit_camelot'], n['entry_camelot'])
bpm_change = abs(r['bpm'] - n['bpm'])
bpm_pct = bpm_change / r['bpm'] * 100 if r['bpm'] > 0 else 0
key_q = "PERFECT" if cam_dist <= 0.5 else "GOOD" if cam_dist <= 1 else "OK" if cam_dist <= 2 else "JARRING"
bpm_q = "smooth" if bpm_pct < 3 else "ok" if bpm_pct < 6 else f"jump ({bpm_pct:.0f}%)"
lines.append(
f"| {r['name']} | {n['name']} | {cam_dist} "
f"({r['exit_camelot']}->{n['entry_camelot']}) "
f"| {bpm_change:.0f} ({bpm_q}) | {key_q} |"
)
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser(
description="Playlist sequencing analysis: keys, Camelot codes, energy, transitions."
)
parser.add_argument(
"--playlist",
help="Path to YAML playlist config file (for ordered analysis with album metadata).",
)
parser.add_argument(
"--audio-dir", default="docs/audio",
help="Directory containing .mp3 files (default: docs/audio).",
)
parser.add_argument(
"--format", choices=["json", "text"], default="json",
help="Output format (default: json).",
)
parser.add_argument(
"-o", "--output",
help="Output file path (default: stdout).",
)
parser.add_argument(
"--archive", nargs="?", const="", default="",
help=(
"Persist full JSON output to a per-playlist archive. "
"With no path: writes to docs/audio-analysis/playlists/<album>.json. "
"Pass an explicit path to override. Default: ON."
),
)
parser.add_argument(
"--no-archive", dest="archive", action="store_const", const=None,
help="Skip writing the JSON archive.",
)
parser.add_argument(
"--companion", nargs="?", const="", default="",
help=(
"Refresh the canonical Markdown companion file. "
"With no path: writes to docs/playlist-sequencing-data.md. "
"Pass an explicit path to override. Default: ON."
),
)
parser.add_argument(
"--no-companion", dest="companion", action="store_const", const=None,
help="Skip refreshing the Markdown companion file.",
)
args = parser.parse_args()
require_audio_deps()
import librosa # noqa: F401
import numpy as np # noqa: F401
# Build track list from playlist config or auto-discovery
if args.playlist:
if not os.path.isfile(args.playlist):
print(json.dumps({
"script": "playlist-sequencing-data",
"status": "fail",
"error": f"Playlist config not found: {args.playlist}",
}), file=sys.stderr)
sys.exit(1)
album_name, track_list = load_playlist(args.playlist)
else:
if not os.path.isdir(args.audio_dir):
print(json.dumps({
"script": "playlist-sequencing-data",
"status": "fail",
"error": f"Audio directory not found: {args.audio_dir}",
}), file=sys.stderr)
sys.exit(1)
album_name, track_list = discover_tracks(args.audio_dir)
if not track_list:
print(json.dumps({
"script": "playlist-sequencing-data",
"status": "fail",
"error": "No tracks found.",
}), file=sys.stderr)
sys.exit(1)
print(f"Analyzing playlist sequencing data for: {album_name}\n", file=sys.stderr)
results = []
for track_name, filename in track_list:
filepath = os.path.join(args.audio_dir, filename)
if not os.path.exists(filepath):
print(f" MISSING: {filename}", file=sys.stderr)
results.append({'name': track_name, 'error': 'file not found'})
continue
print(f" {track_name}...", end="", flush=True, file=sys.stderr)
data = analyze_track(filepath)
data['name'] = track_name
results.append(data)
print(
f" {data['bpm']} BPM | {data['overall_key']} ({data['overall_camelot']}) "
f"| Entry: {data['entry_camelot']} | Exit: {data['exit_camelot']} "
f"| E:{data['energy_level']}",
file=sys.stderr,
)
# Compute transition data for JSON output
for i in range(len(results) - 1):
if 'error' in results[i] or 'error' in results[i+1]:
continue
r = results[i]
n = results[i+1]
cam_dist = camelot_distance(r['exit_camelot'], n['entry_camelot'])
bpm_pct = abs(r['bpm'] - n['bpm']) / r['bpm'] * 100 if r['bpm'] > 0 else 0
key_quality = "PERFECT" if cam_dist <= 0.5 else "GOOD" if cam_dist <= 1 else "OK" if cam_dist <= 2 else "JARRING"
bpm_quality = "smooth" if bpm_pct < 3 else "ok" if bpm_pct < 6 else f"jump ({bpm_pct:.0f}%)"
r['transition'] = {
'to': n['name'],
'camelot_distance': cam_dist,
'key_quality': key_quality,
'bpm_change': round(abs(r['bpm'] - n['bpm']), 1),
'bpm_quality': bpm_quality,
}
# Format output
if args.format == "json":
output = format_json(album_name, results)
else:
output = format_text(album_name, results)
# Write output
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"\nReport saved to: {args.output}", file=sys.stderr)
else:
print(output)
# JSON archive (default ON unless --no-archive)
archive_target = resolve_archive_arg("playlists", album_name, args.archive)
if archive_target is not None:
try:
json_data = json.loads(format_json(album_name, results))
except Exception as exc:
print(f" WARN: archive skipped — JSON build failed: {exc}", file=sys.stderr)
else:
res = write_archive(archive_target, json_data)
print(f" ARCHIVED: {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
# Companion .md refresh (default ON unless --no-companion).
# The body includes its own title + timestamp at the top so each refresh
# updates them. Hand-curated sections live OUTSIDE the AUTOGEN markers
# in the companion file and are preserved across refreshes.
# Per-album companion path: docs/{album-slug}-playlist-sequencing.md so
# multiple bands don't overwrite each other's companions.
companion_target = resolve_companion_path(SCRIPT_NAME, args.companion, album=album_name)
if companion_target is not None:
from datetime import datetime, timezone as _tz
timestamp = datetime.now(_tz.utc).isoformat()
title_block = (
f"# {album_name} — Playlist Sequencing Data\n"
f"_Generated by `{SCRIPT_NAME}` on {timestamp}_\n\n"
)
# Drop the script's built-in title (first 2 lines) and keep the rest
body_lines = format_text(album_name, results).split("\n")
cut = 0
while cut < len(body_lines):
line = body_lines[cut]
if line.startswith("##") or (line.strip() and not line.startswith("#")):
break
cut += 1
md_body = title_block + "\n".join(body_lines[cut:])
res = update_companion(companion_target, SCRIPT_NAME, md_body)
print(f" COMPANION: {res['status']} {res['path']} ({res['bytes_written']} bytes)", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["librosa>=0.10", "numpy>=1.24"]
# ///
"""Detailed tempo analysis -- shows BPM over time to detect tempo changes
and off-beats.
Usage:
python tempo-detail.py <audio-file> [options]
# Analyze a single track
python tempo-detail.py track.mp3
# JSON output to file
python tempo-detail.py track.mp3 --format json -o results.json
Exit codes:
0 = success
1 = invalid arguments or runtime error
2 = missing dependencies
"""
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 audio_deps import require_audio_deps
SCRIPT_NAME = "tempo-detail"
VERSION = "1.0.0"
def analyze_tempo_text(filepath):
"""Run tempo analysis with text output (original format)."""
import numpy as np
print(f"Loading: {filepath}")
y, sr = librosa.load(filepath, sr=22050)
duration = librosa.get_duration(y=y, sr=sr)
print(f"Duration: {int(duration//60)}:{int(duration%60):02d}")
# Overall tempo
tempo_overall, beats = librosa.beat.beat_track(y=y, sr=sr)
tempo_val = float(tempo_overall[0]) if hasattr(tempo_overall, '__len__') else float(tempo_overall)
print(f"\nOverall BPM: {tempo_val:.1f}")
# Beat times
beat_times = librosa.frames_to_time(beats, sr=sr)
if len(beat_times) < 4:
print("Too few beats detected for detailed analysis.")
return
# Inter-beat intervals
ibis = np.diff(beat_times)
local_bpms = 60.0 / ibis
# Show tempo in ~15-second windows
print(f"\n{'Time Window':<20} {'Avg BPM':>8} {'Min BPM':>8} {'Max BPM':>8} {'Stability':>10}")
print("-" * 60)
window_size = 15 # seconds
num_windows = int(np.ceil(duration / window_size))
for i in range(num_windows):
start = i * window_size
end = min((i + 1) * window_size, duration)
mask = (beat_times[:-1] >= start) & (beat_times[:-1] < end)
window_bpms = local_bpms[mask]
if len(window_bpms) > 0:
avg = np.mean(window_bpms)
mn = np.min(window_bpms)
mx = np.max(window_bpms)
std = np.std(window_bpms)
stability = "steady" if std < 5 else "slight variation" if std < 15 else "TEMPO CHANGE"
time_label = f"{int(start//60)}:{int(start%60):02d}-{int(end//60)}:{int(end%60):02d}"
print(f"{time_label:<20} {avg:>8.1f} {mn:>8.1f} {mx:>8.1f} {stability:>10}")
# Detect significant tempo shifts between consecutive beats
print("\n--- Potential Tempo Events ---")
found = False
for i in range(len(local_bpms) - 1):
diff = abs(local_bpms[i+1] - local_bpms[i])
if diff > 20:
t = beat_times[i+1]
print(f" {int(t//60)}:{int(t%60):02d}.{int((t%1)*10)} \u2014 BPM jumps from {local_bpms[i]:.0f} to {local_bpms[i+1]:.0f} (\u0394{diff:.0f})")
found = True
if not found:
print(" No significant tempo shifts detected (all beat-to-beat changes < 20 BPM)")
# Odd time / irregular beat detection
print("\n--- Beat Regularity ---")
median_ibi = np.median(ibis)
irregular = []
for i, ibi in enumerate(ibis):
ratio = ibi / median_ibi
if ratio < 0.75 or ratio > 1.33:
t = beat_times[i]
pct = (ratio - 1) * 100
irregular.append((t, ratio, pct))
if irregular:
print(f" {len(irregular)} irregular beats detected (>33% deviation from median):")
for t, ratio, pct in irregular[:15]:
label = "shorter" if ratio < 1 else "longer"
print(f" {int(t//60)}:{int(t%60):02d}.{int((t%1)*10)} \u2014 beat is {abs(pct):.0f}% {label} than expected")
else:
print(" All beats within normal variance \u2014 consistent 4/4 feel")
def analyze_tempo_json(filepath):
"""Run tempo analysis and return structured data for JSON output."""
import numpy as np
y, sr = librosa.load(filepath, sr=22050)
duration = librosa.get_duration(y=y, sr=sr)
tempo_overall, beats = librosa.beat.beat_track(y=y, sr=sr)
tempo_val = float(tempo_overall[0]) if hasattr(tempo_overall, '__len__') else float(tempo_overall)
beat_times = librosa.frames_to_time(beats, sr=sr)
if len(beat_times) < 4:
return {
"script": SCRIPT_NAME,
"version": VERSION,
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"metrics": {
"file": str(Path(filepath).name),
"duration_seconds": round(duration, 2),
"bpm_overall": round(tempo_val, 1),
"beats_detected": len(beat_times),
"note": "Too few beats for detailed analysis",
},
"findings": [],
"summary": {"total": 0},
}
ibis = np.diff(beat_times)
local_bpms = 60.0 / ibis
# Tempo windows
window_size = 15
num_windows = int(np.ceil(duration / window_size))
windows = []
for i in range(num_windows):
start = i * window_size
end = min((i + 1) * window_size, duration)
mask = (beat_times[:-1] >= start) & (beat_times[:-1] < end)
window_bpms = local_bpms[mask]
if len(window_bpms) > 0:
avg = float(np.mean(window_bpms))
mn = float(np.min(window_bpms))
mx = float(np.max(window_bpms))
std = float(np.std(window_bpms))
stability = "steady" if std < 5 else "slight_variation" if std < 15 else "tempo_change"
windows.append({
"time_start": start,
"time_end": round(end, 2),
"avg_bpm": round(avg, 1),
"min_bpm": round(mn, 1),
"max_bpm": round(mx, 1),
"std_bpm": round(std, 2),
"stability": stability,
})
# Tempo events (>20 BPM jump)
tempo_events = []
for i in range(len(local_bpms) - 1):
diff = abs(local_bpms[i+1] - local_bpms[i])
if diff > 20:
t = float(beat_times[i+1])
tempo_events.append({
"time": round(t, 2),
"from_bpm": round(float(local_bpms[i]), 1),
"to_bpm": round(float(local_bpms[i+1]), 1),
"delta": round(float(diff), 1),
})
# Beat regularity
median_ibi = float(np.median(ibis))
irregular_beats = []
for i, ibi in enumerate(ibis):
ratio = ibi / median_ibi
if ratio < 0.75 or ratio > 1.33:
t = float(beat_times[i])
pct = (ratio - 1) * 100
irregular_beats.append({
"time": round(t, 2),
"ratio": round(float(ratio), 3),
"deviation_pct": round(float(abs(pct)), 1),
"direction": "shorter" if ratio < 1 else "longer",
})
return {
"script": SCRIPT_NAME,
"version": VERSION,
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": "pass",
"metrics": {
"file": str(Path(filepath).name),
"duration_seconds": round(duration, 2),
"bpm_overall": round(tempo_val, 1),
"beats_detected": len(beat_times),
"median_inter_beat_interval": round(median_ibi, 4),
"tempo_windows": windows,
"tempo_events": tempo_events,
"irregular_beats": irregular_beats,
"irregular_beat_count": len(irregular_beats),
},
"findings": [],
"summary": {"total": 0},
}
def main():
require_audio_deps()
import librosa as _librosa # noqa: E402
import numpy as np # noqa: E402, F401
# Make librosa available to module-level helper functions
globals()["librosa"] = _librosa
parser = argparse.ArgumentParser(
description="Detailed tempo analysis -- BPM over time, stability, beat regularity.",
)
parser.add_argument(
"audio_file",
help="Path to the audio file to analyze",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
dest="output_format",
help="Output format (default: json)",
)
parser.add_argument(
"-o", "--output",
default=None,
help="Output file path (default: stdout)",
)
args = parser.parse_args()
if args.output_format == "text":
analyze_tempo_text(args.audio_file)
else:
result = analyze_tempo_json(args.audio_file)
output = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(output + "\n")
else:
print(output)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,288 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=7.0"]
# ///
"""Tests for map-adjustments.py"""
import json
import subprocess
import sys
from pathlib import Path
SCRIPT = str(Path(__file__).parent.parent / "map-adjustments.py")
def run_script(input_data: dict | str | None = None) -> tuple[int, dict]:
"""Run map-adjustments.py with stdin input and return (exit_code, parsed_json)."""
cmd = [sys.executable, SCRIPT, "--stdin"]
input_str = json.dumps(input_data) if isinstance(input_data, dict) else (input_data or "")
result = subprocess.run(cmd, input=input_str, capture_output=True, text=True)
try:
output = json.loads(result.stdout)
except json.JSONDecodeError:
output = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
return result.returncode, output
def test_single_dimension():
"""Single dimension should produce relevant adjustments."""
data = {"dimensions": [{"dimension": "vocals", "direction": "too_polished"}]}
code, output = run_script(data)
assert code == 0
assert output["status"] == "pass"
adj = output["adjustments"]
assert "raw vocal" in adj["style_prompt"]["add_descriptors"]
assert any("polished" in p for p in adj["style_prompt"]["remove_patterns"])
def test_multiple_dimensions():
"""Multiple dimensions should combine adjustments."""
data = {
"dimensions": [
{"dimension": "vocals", "direction": "too_polished"},
{"dimension": "energy", "direction": "too_low"},
]
}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
# Should have vocal adjustments
assert "raw vocal" in adj["style_prompt"]["add_descriptors"]
# Should have energy adjustments
assert "high energy" in adj["style_prompt"]["add_descriptors"]
def test_slider_adjustments_paid_tier():
"""Paid tier should get direct slider recommendations."""
data = {
"dimensions": [{"dimension": "vibe", "direction": "too_generic"}],
"tier": "pro",
}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "sliders" in adj
assert "weirdness" in adj["sliders"]
assert "note" not in adj["sliders"] # No "not available" note for paid tier
def test_slider_adjustments_free_tier():
"""Free tier should get slider note about unavailability."""
data = {
"dimensions": [{"dimension": "vibe", "direction": "too_generic"}],
"tier": "free",
}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "sliders" in adj
assert "note" in adj["sliders"] # Should have unavailability note
assert "recommended_if_upgraded" in adj["sliders"]
def test_lyric_changes():
"""Structure dimensions should produce lyric change recommendations."""
data = {"dimensions": [{"dimension": "structure", "direction": "needs_bridge"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "lyrics" in adj
assert len(adj["lyrics"]["changes"]) > 0
assert "Bridge" in adj["lyrics"]["changes"][0]
def test_unknown_dimension():
"""Unknown dimension should produce a note, not fail."""
data = {"dimensions": [{"dimension": "color", "direction": "too_blue"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "notes" in adj
assert any("Unknown dimension" in n for n in adj["notes"])
def test_unknown_direction():
"""Unknown direction for valid dimension should produce a note."""
data = {"dimensions": [{"dimension": "vocals", "direction": "too_purple"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "notes" in adj
assert any("Unknown direction" in n for n in adj["notes"])
def test_deduplication():
"""Duplicate descriptors should be deduped."""
data = {
"dimensions": [
{"dimension": "energy", "direction": "too_low"},
{"dimension": "energy", "direction": "too_low"},
]
}
code, output = run_script(data)
assert code == 0
add_descs = output["adjustments"]["style_prompt"]["add_descriptors"]
assert len(add_descs) == len(set(add_descs)), "Descriptors should be deduped"
def test_missing_dimensions_field():
"""Missing dimensions should fail."""
code, output = run_script({"tier": "pro"})
assert code == 1
assert output["status"] == "fail"
def test_invalid_json():
"""Invalid JSON should fail."""
code, output = run_script("not json")
assert code == 1
assert output["status"] == "fail"
def test_empty_dimensions():
"""Empty dimensions array should pass with empty adjustments."""
data = {"dimensions": []}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert adj["style_prompt"]["add_descriptors"] == []
assert adj["style_prompt"]["remove_patterns"] == []
def test_exclusion_generation():
"""Dimensions with exclusion recommendations should populate exclusions."""
data = {"dimensions": [{"dimension": "instrumentation", "direction": "too_much"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert len(adj["exclusions"]["add"]) > 0
def test_dimension_with_note():
"""Dimensions that need further clarification should include notes."""
data = {"dimensions": [{"dimension": "music", "direction": "general_issue"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "notes" in adj
assert any("further narrowing" in n.lower() for n in adj["notes"])
def test_quality_robotic_vocals():
"""Quality dimension robotic_vocals should produce style and exclusion adjustments."""
data = {"dimensions": [{"dimension": "quality", "direction": "robotic_vocals"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "natural vocal" in adj["style_prompt"]["add_descriptors"]
assert "no auto-tune" in adj["exclusions"]["add"]
def test_quality_clipping():
"""Quality dimension clipping should add clean mix descriptors and remove heavy patterns."""
data = {"dimensions": [{"dimension": "quality", "direction": "clipping"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "clean mix" in adj["style_prompt"]["add_descriptors"]
assert "heavy" in adj["style_prompt"]["remove_patterns"]
def test_quality_muffled():
"""Quality dimension muffled should add crisp descriptors."""
data = {"dimensions": [{"dimension": "quality", "direction": "muffled"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "crisp" in adj["style_prompt"]["add_descriptors"]
assert "lo-fi" in adj["style_prompt"]["remove_patterns"]
def test_quality_artifacts_note():
"""Quality dimension artifacts should produce a note about regeneration."""
data = {"dimensions": [{"dimension": "quality", "direction": "artifacts"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "notes" in adj
assert any("regenerate" in n.lower() for n in adj["notes"])
def test_length_too_short():
"""Length dimension too_short should produce lyric change recommendations."""
data = {"dimensions": [{"dimension": "length", "direction": "too_short"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "lyrics" in adj
assert any("extend" in c.lower() or "add sections" in c.lower() for c in adj["lyrics"]["changes"])
def test_length_outro_cuts_off():
"""Length dimension outro_cuts_off should recommend Outro and Fade Out."""
data = {"dimensions": [{"dimension": "length", "direction": "outro_cuts_off"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "lyrics" in adj
assert any("Outro" in c for c in adj["lyrics"]["changes"])
def test_length_pacing_drags():
"""Length dimension pacing_drags should recommend energy metatags."""
data = {"dimensions": [{"dimension": "length", "direction": "pacing_drags"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "lyrics" in adj
assert any("Energy" in c or "Build-Up" in c for c in adj["lyrics"]["changes"])
def test_consistency_check_no_conflicts():
"""Clean adjustments should produce no consistency warnings."""
data = {"dimensions": [{"dimension": "vocals", "direction": "too_polished"}]}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "consistency_warnings" not in adj
def test_consistency_check_add_remove_conflict():
"""Conflicting add/remove should produce a consistency warning."""
# instrumentation too_little adds "lush arrangement" etc. but also combine with
# production too_polished which adds "lo-fi" and removes "crisp", "polished"
# We need a case where add and remove overlap. Let's use energy too_high (adds "gentle", "soft")
# combined with energy too_low (adds "high energy" and removes "gentle", "soft")
data = {
"dimensions": [
{"dimension": "energy", "direction": "too_high"},
{"dimension": "energy", "direction": "too_low"},
]
}
code, output = run_script(data)
assert code == 0
adj = output["adjustments"]
assert "consistency_warnings" in adj
conflict_types = [w["type"] for w in adj["consistency_warnings"]]
assert "add_remove_conflict" in conflict_types
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
print(f" PASS: {test.__name__}")
except AssertionError as e:
failed += 1
print(f" FAIL: {test.__name__}: {e}")
except Exception as e:
failed += 1
print(f" ERROR: {test.__name__}: {e}")
print(f"\n{passed} passed, {failed} failed out of {len(tests)} tests")
sys.exit(1 if failed else 0)

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=7.0"]
# ///
"""Tests for parse-feedback.py"""
import json
import subprocess
import sys
from pathlib import Path
SCRIPT = str(Path(__file__).parent.parent / "parse-feedback.py")
def run_script(input_data: dict | str | None = None, extra_args: list[str] | None = None) -> tuple[int, dict]:
"""Run parse-feedback.py with stdin input and return (exit_code, parsed_json)."""
cmd = [sys.executable, SCRIPT, "--stdin"]
if extra_args:
cmd.extend(extra_args)
input_str = json.dumps(input_data) if isinstance(input_data, dict) else (input_data or "")
result = subprocess.run(cmd, input=input_str, capture_output=True, text=True)
try:
output = json.loads(result.stdout)
except json.JSONDecodeError:
output = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
return result.returncode, output
def test_valid_minimal_input():
"""Minimal valid input: just feedback_text."""
code, output = run_script({"feedback_text": "The guitar is too loud"})
assert code == 0, f"Expected exit 0, got {code}: {output}"
assert output["status"] == "pass"
assert output["parsed"]["feedback_text"] == "The guitar is too loud"
assert output["summary"]["total"] == 0
def test_valid_full_input():
"""Full valid input with all optional fields."""
data = {
"feedback_text": "It feels too polished",
"original_style_prompt": "indie folk, acoustic, warm",
"original_lyrics": "[Verse]\nSome lyrics here",
"band_profile": "midnight-wanderers",
"model": "v5 Pro",
"slider_settings": {"weirdness": 45, "style_influence": 60},
"intent": "I wanted a raw, intimate feel",
"feedback_type": "clear",
"dimensions": ["production", "vocals"],
}
code, output = run_script(data)
assert code == 0
assert output["status"] == "pass"
assert output["parsed"]["context"]["model"] == "v5 Pro"
assert output["parsed"]["context"]["band_profile"] == "midnight-wanderers"
assert output["parsed"]["pre_categorized"]["feedback_type"] == "clear"
assert output["parsed"]["pre_categorized"]["dimensions"] == ["production", "vocals"]
def test_missing_feedback_text():
"""Missing feedback_text should fail."""
code, output = run_script({"model": "v5 Pro"})
assert code == 1
assert output["status"] == "fail"
assert output["summary"]["critical"] >= 1
def test_empty_feedback_text():
"""Empty feedback_text should fail."""
code, output = run_script({"feedback_text": " "})
assert code == 1
assert output["status"] == "fail"
assert output["summary"]["critical"] >= 1
def test_unrecognized_model_info():
"""Unrecognized model should produce an info finding and still pass."""
code, output = run_script({"feedback_text": "Sounds off", "model": "v99 Ultra"})
assert code == 0
assert output["status"] == "pass", f"Expected pass (info-only findings), got {output['status']}"
info_findings = [f for f in output["findings"] if f["severity"] == "info"]
assert len(info_findings) >= 1
assert "Unrecognized model" in info_findings[0]["issue"]
assert "informational" in info_findings[0]["fix"]
def test_invalid_dimension():
"""Invalid dimension should produce a low-severity finding but pass."""
code, output = run_script({"feedback_text": "Too bright", "dimensions": ["brightness"]})
assert code == 0
assert output["status"] == "warning"
assert output["summary"]["low"] >= 1
def test_invalid_feedback_type():
"""Invalid feedback_type should produce a warning."""
code, output = run_script({"feedback_text": "Hmm", "feedback_type": "confused"})
assert code == 0
assert output["status"] == "warning"
def test_invalid_slider_range():
"""Slider value out of range should warn."""
code, output = run_script({
"feedback_text": "Off",
"slider_settings": {"weirdness": 150},
})
assert code == 0
assert output["status"] == "warning"
assert output["summary"]["medium"] >= 1
def test_invalid_json_input():
"""Non-JSON input should fail."""
code, output = run_script("this is not json")
assert code == 1
assert output["status"] == "fail"
def test_non_object_json():
"""JSON array (not object) should fail."""
cmd = [sys.executable, SCRIPT, "--stdin"]
result = subprocess.run(cmd, input="[1, 2, 3]", capture_output=True, text=True)
assert result.returncode == 1
output = json.loads(result.stdout)
assert output["status"] == "fail"
def test_dimensions_not_array():
"""dimensions as non-array should produce high severity finding."""
code, output = run_script({"feedback_text": "Bad", "dimensions": "vocals"})
assert code == 1
assert output["status"] == "fail"
assert output["summary"]["high"] >= 1
def test_empty_context_stripped():
"""Empty optional context fields should be stripped from output."""
code, output = run_script({"feedback_text": "Good stuff"})
assert code == 0
# Context should only have non-empty fields
assert "model" not in output["parsed"]["context"]
assert "band_profile" not in output["parsed"]["context"]
def test_technical_feedback_type():
"""'technical' should be a valid feedback type."""
code, output = run_script({"feedback_text": "There are artifacts", "feedback_type": "technical"})
assert code == 0
assert output["status"] == "pass"
assert output["summary"]["total"] == 0
def test_length_dimension_valid():
"""'length' should be a valid dimension."""
code, output = run_script({"feedback_text": "Song is too short", "dimensions": ["length"]})
assert code == 0
assert output["status"] == "pass"
assert output["summary"]["low"] == 0
def test_quality_dimension_valid():
"""'quality' should be a valid dimension."""
code, output = run_script({"feedback_text": "Audio has clipping", "dimensions": ["quality"]})
assert code == 0
assert output["status"] == "pass"
assert output["summary"]["low"] == 0
def test_unrecognized_model_passes_through():
"""Unrecognized model should still appear in parsed output context."""
code, output = run_script({"feedback_text": "Test", "model": "v99 Ultra"})
assert code == 0
assert output["parsed"]["context"]["model"] == "v99 Ultra"
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
print(f" PASS: {test.__name__}")
except AssertionError as e:
failed += 1
print(f" FAIL: {test.__name__}: {e}")
except Exception as e:
failed += 1
print(f" ERROR: {test.__name__}: {e}")
print(f"\n{passed} passed, {failed} failed out of {len(tests)} tests")
sys.exit(1 if failed else 0)