feat: Maestro Mode - context-aware prompt generation

- Backend: POST /api/text-gen/maestro endpoint with dual modes
  - Empty prompt → generate from scratch (temp 1.0)
  - Non-empty prompt → complete/extend (temp 0.85)
  - structured=true → returns JSON fields for Advanced/Pro mode
  - Three system prompts: MAESTRO_GENERATE, MAESTRO_COMPLETE, MAESTRO_STRUCTURED
  - Trailing sentence cleanup for plain text mode
  - JSON fallback parsing for structured mode

- Frontend: MaestroButton component
  - Context-aware labels: ♪ Maestro Mode (empty) / ♪ Keep Composing (has content)
  - Loading states: animating ♩ with Dreaming.../Composing...
  - structured prop for Pro mode → populates structured fields
  - onStructuredResult callback merges fields into StructuredPrompt

- UX: Replaced 'Get Suggestions ✨' with Maestro Mode
  - Suggestions required text in the box; Maestro works with or without
  - Single button, context-aware behavior, cleaner UI
  - Advanced/Pro mode gets structured fields from Maestro
  - Removed SuggestionsModal dependency

- Rename: 'Compose for Me' → 'Keep Composing'
This commit is contained in:
rin.kagamine committed 2026-05-31 10:13:43 -04:00
1 parent 816f8fbd2f
commit e0ce9e4b6c
4 files changed
+304 -33

No files matched your search

+21 -1
View File
@@ -15,7 +15,7 @@ from kithara_client import generate_clip, save_clip_file
from waveform import generate_waveform_svg, get_waveform_path from waveform import generate_waveform_svg, get_waveform_path
from analysis import analyze_clip from analysis import analyze_clip
from image_gen import generate_artwork, get_image_gen_config, get_available_models, SAFETY_LEVELS from image_gen import generate_artwork, get_image_gen_config, get_available_models, SAFETY_LEVELS
from text_gen import suggest_prompts, get_text_gen_config, get_available_models as get_text_models from text_gen import suggest_prompts, maestro_prompt, get_text_gen_config, get_available_models as get_text_models
app = FastAPI(title="Kithara Studio", version="0.1.0") app = FastAPI(title="Kithara Studio", version="0.1.0")
@@ -447,6 +447,26 @@ async def get_suggestions(request: dict):
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Suggestion generation failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Suggestion generation failed: {str(e)}")
@app.post("/api/text-gen/maestro")
async def maestro(request: dict):
"""Generate or complete a music prompt using Maestro Mode.
If hint is empty/missing: generates a creative prompt from scratch ("Dream for Me").
If hint is provided: completes the user's existing prompt ("Keep Composing").
If structured=true: returns JSON fields for Advanced/Pro mode instead of plain text.
"""
hint = request.get("hint", "").strip()
model = request.get("model") # Optional text model override
structured = request.get("structured", False) # Return structured fields for Pro mode
try:
config = get_text_gen_config()
result = await maestro_prompt(hint=hint, model=model, config=config, structured=structured)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Maestro generation failed: {str(e)}")
@app.patch("/api/clips/{clip_id}") @app.patch("/api/clips/{clip_id}")
async def rename_clip_endpoint(clip_id: str, request: RenameRequest): async def rename_clip_endpoint(clip_id: str, request: RenameRequest):
clip = await get_clip(clip_id) clip = await get_clip(clip_id)
+152 -1
View File
@@ -1,6 +1,8 @@
"""Text generation client using Google Gemini models for prompt suggestions.""" """Text generation client using Google Gemini models for prompt suggestions."""
import json
import os import os
import re
from typing import Optional from typing import Optional
from google import genai from google import genai
@@ -93,7 +95,6 @@ Format your response as a numbered list (1., 2., 3.) with just the prompts, no e
suggestions = [] suggestions = []
# Split by numbered lines (1., 2., 3.) # Split by numbered lines (1., 2., 3.)
import re
# Find all text after numbers # Find all text after numbers
matches = re.findall(r'(?:^|\n)\d+\.\s*(.*?)(?=\n\d+\.|$)', text, re.DOTALL) matches = re.findall(r'(?:^|\n)\d+\.\s*(.*?)(?=\n\d+\.|$)', text, re.DOTALL)
if matches: if matches:
@@ -106,6 +107,156 @@ Format your response as a numbered list (1., 2., 3.) with just the prompts, no e
return suggestions[:3] # Ensure max 3 return suggestions[:3] # Ensure max 3
# ── Maestro Mode ──────────────────────────────────────────────────────────
MAESTRO_GENERATE_PROMPT = """You are Maestro, an expert music prompt writer for AI music generation models (specifically Google Lyria).
Your task: Write a SINGLE compelling, detailed music generation prompt from scratch. The user has given you no starting point — be creative and bold.
Pick any genre, style, or mood that inspires you. The prompt should be:
- A single paragraph, vivid and evocative
- Rich in musical detail (genre, instrumentation, mood, tempo, structure, production style)
- Written in terminology that music producers use
- Between 2-4 sentences long
- Something that would produce an INTERESTING piece of music — surprise the user
Vary your choices! Don't default to the same genre or mood every time. Draw from the full spectrum of music: electronic, orchestral, jazz, world, experimental, ambient, industrial, folk, hip-hop, and beyond.
Output ONLY the prompt text. No numbers, no labels, no commentary."""
MAESTRO_COMPLETE_PROMPT = """You are Maestro, an expert music prompt writer for AI music generation models (specifically Google Lyria).
The user has started writing a prompt and wants you to complete it. Here is their prompt so far:
"{user_input}"
Complete this prompt into a detailed, evocative music generation prompt. Your completion should:
- Build on the user's existing idea naturally
- Add specific details about genre, instrumentation, mood, tempo, structure, or production style
- Result in a single paragraph that's ready to use as-is
- Be between 2-4 sentences total (including what the user already wrote)
Do NOT repeat what the user wrote — continue from where they left off and blend it into a complete, polished prompt.
Output ONLY the completed prompt text. No numbers, no labels, no commentary."""
MAESTRO_STRUCTURED_PROMPT = """You are Maestro, an expert music prompt writer for AI music generation models (specifically Google Lyria).
{context_instruction}
Generate a detailed music generation prompt, filling in as many of these fields as appropriate:
- style: Genre and style (e.g., "Industrial techno", "Cinematic orchestral", "Lo-fi hip hop")
- timbre: Sound texture and tone quality (e.g., "Warm analog synths with gritty distortion layers")
- structure: Song structure (e.g., "Intro-Build-Drop-Breakdown-Drop-Outro")
- mood: Emotional quality and energy (e.g., "Dark and aggressive, euphoric climax")
- tempo: BPM and rhythmic feel (e.g., "180 BPM, driving four-on-the-floor")
- instrumentation: Specific instruments and sounds (e.g., "Distorted 909 kick, reverbed vocal chops, sub-bass")
- effects: Production effects and processing (e.g., "Heavy sidechain compression, bitcrushed melodies")
- additional: Any extra details, references, or instructions
You MUST output valid JSON with those exact field names. Only include fields where you have specific, meaningful content. Leave fields as empty strings if you don't have a good detail for them. Do NOT include any fields beyond these 8.
Be creative and varied! Draw from the full spectrum of music — electronic, orchestral, jazz, world, experimental, ambient, industrial, folk, hip-hop, and beyond.
Output ONLY the JSON object. No markdown, no commentary, no explanation."""
async def maestro_prompt(hint: str = "", model: str = None, config: dict = None, structured: bool = False) -> dict:
"""Generate or complete a music prompt using Maestro Mode.
Args:
hint: User's existing text. Empty = generate from scratch, non-empty = complete.
model: Text model to use. Defaults to configured model.
config: Optional config override.
structured: If True, return structured JSON fields for Advanced/Pro mode.
Returns:
Plain: dict with "prompt" (text) and "mode" ("generate" | "complete")
Structured: dict with "fields" (JSON) and "mode" ("generate" | "complete")
"""
cfg = config or get_text_gen_config()
client = genai.Client(api_key=cfg["api_key"])
safety = build_safety_settings(cfg)
# Auto-select mode based on input
mode = "generate" if not hint.strip() else "complete"
if structured:
# Structured mode: return JSON fields for Advanced/Pro prompt
context_instruction = (
"The user wants you to generate a creative music prompt from scratch. Surprise them with something interesting."
if mode == "generate"
else f"The user has started describing music they want. Here's what they have so far:\n\n\"{hint.strip()}\"\n\nBuild on their idea, filling in the structured fields with rich, specific details."
)
system = MAESTRO_STRUCTURED_PROMPT.format(context_instruction=context_instruction)
user_content = "Generate structured music prompt fields."
temperature = 0.95 if mode == "generate" else 0.8
max_tokens = 1536
response = client.models.generate_content(
model=model or cfg["model"],
contents=user_content,
config=types.GenerateContentConfig(
system_instruction=system,
safety_settings=safety,
temperature=temperature,
max_output_tokens=max_tokens,
response_mime_type="application/json",
),
)
try:
fields = json.loads(response.text.strip())
except json.JSONDecodeError:
# Fallback: try to extract JSON from markdown code blocks
match = re.search(r'```(?:json)?\s*([\s\S]*?)```', response.text)
if match:
fields = json.loads(match.group(1).strip())
else:
# Last resort: put everything in additional
fields = {"additional": response.text.strip()}
return {"fields": fields, "mode": mode}
else:
# Plain text mode: original behavior for Basic prompt
if mode == "generate":
system = MAESTRO_GENERATE_PROMPT
user_content = "Write me a music generation prompt. Surprise me."
temperature = 1.0
max_tokens = 1024
else:
system = MAESTRO_COMPLETE_PROMPT.format(user_input=hint.strip())
user_content = "Complete this prompt into a finished, ready-to-use music generation prompt. End with a complete sentence."
temperature = 0.85
max_tokens = 1536
response = client.models.generate_content(
model=model or cfg["model"],
contents=user_content,
config=types.GenerateContentConfig(
system_instruction=system,
safety_settings=safety,
temperature=temperature,
max_output_tokens=max_tokens,
),
)
text = response.text.strip()
# Remove any leading numbering or labels the model might add
text = re.sub(r'^\d+\.\s*', '', text)
# Clean up trailing incomplete sentences (cut at last period)
if text and not text[-1] in '.!?':
last_period = max(text.rfind('.'), text.rfind('!'), text.rfind('?'))
if last_period > len(text) // 2: # Only cut if we're past halfway
text = text[:last_period + 1]
return {"prompt": text, "mode": mode}
# ── Available Models ─────────────────────────────────────────────────────── # ── Available Models ───────────────────────────────────────────────────────
AVAILABLE_TEXT_MODELS = [ AVAILABLE_TEXT_MODELS = [
+33 -31
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import StructuredPromptEditor, { PromptStructure } from './StructuredPromptEditor' import StructuredPromptEditor, { PromptStructure } from './StructuredPromptEditor'
import SuggestionsModal from './SuggestionsModal' import MaestroButton from './MaestroButton'
interface ModelInfo { interface ModelInfo {
name: string name: string
@@ -24,7 +24,7 @@ export default function Generator({ onGenerate }: Props) {
const [models, setModels] = useState<Record<string, ModelInfo>>({}) const [models, setModels] = useState<Record<string, ModelInfo>>({})
const [promptMode, setPromptMode] = useState<PromptMode>('basic') const [promptMode, setPromptMode] = useState<PromptMode>('basic')
const [structuredPrompt, setStructuredPrompt] = useState<PromptStructure>({}) const [structuredPrompt, setStructuredPrompt] = useState<PromptStructure>({})
const [suggestionsOpen, setSuggestionsOpen] = useState(false)
useEffect(() => { useEffect(() => {
fetch('/api/models') fetch('/api/models')
@@ -185,14 +185,16 @@ export default function Generator({ onGenerate }: Props) {
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<label className="text-xs text-kithara-muted">Prompt</label> <label className="text-xs text-kithara-muted">Prompt</label>
<button <MaestroButton
type="button" prompt={prompt}
onClick={() => setSuggestionsOpen(true)} onResult={(result, mode) => {
disabled={!prompt.trim()} setPrompt(result)
className="text-xs text-kithara-accent hover:text-kithara-accent/80 transition-colors disabled:opacity-40" if (mode === 'generate') {
> setError('')
Get Suggestions ✨ }
</button> }}
onError={(msg) => setError(msg)}
/>
</div> </div>
<textarea <textarea
value={prompt} value={prompt}
@@ -208,14 +210,26 @@ export default function Generator({ onGenerate }: Props) {
<div> <div>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<label className="text-xs text-kithara-muted">Structured Prompt</label> <label className="text-xs text-kithara-muted">Structured Prompt</label>
<button <MaestroButton
type="button" prompt={Object.values(structuredPrompt).filter(Boolean).join(' ')}
onClick={() => setSuggestionsOpen(true)} structured={isPro}
disabled={Object.values(structuredPrompt).every(v => !v?.trim())} onResult={(result, mode) => {
className="text-xs text-kithara-accent hover:text-kithara-accent/80 transition-colors disabled:opacity-40" // Fallback: plain text mode for non-Pro, put result in 'additional'
> setStructuredPrompt(prev => ({ ...prev, additional: result }))
Get Suggestions ✨ if (mode === 'generate') setError('')
</button> }}
onStructuredResult={(fields, mode) => {
// Structured mode: merge fields into structuredPrompt, preserving user edits
setStructuredPrompt(prev => ({
...prev,
...Object.fromEntries(
Object.entries(fields).filter(([_, v]) => v?.trim())
),
}))
if (mode === 'generate') setError('')
}}
onError={(msg) => setError(msg)}
/>
</div> </div>
<StructuredPromptEditor <StructuredPromptEditor
value={structuredPrompt} value={structuredPrompt}
@@ -237,19 +251,7 @@ export default function Generator({ onGenerate }: Props) {
</button> </button>
</form> </form>
<SuggestionsModal
isOpen={suggestionsOpen}
onClose={() => setSuggestionsOpen(false)}
onSelect={(suggestion) => {
if (promptMode === 'basic' || !isPro) {
setPrompt(suggestion)
} else {
// For advanced mode, put it in the 'style' field or additional
setStructuredPrompt(prev => ({ ...prev, additional: suggestion }))
}
}}
userInput={promptMode === 'basic' || !isPro ? prompt : Object.values(structuredPrompt).filter(Boolean).join(' ')}
/>
</div> </div>
) )
} }
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react'
import type { PromptStructure } from './StructuredPromptEditor'
interface Props {
/** Current prompt text. Empty = generate mode, non-empty = complete mode. */
prompt: string
/** Called with the Maestro result — the generated or completed prompt. */
onResult: (result: string, mode: 'generate' | 'complete') => void
/** Called when Maestro encounters an error. */
onError: (message: string) => void
/** Optional CSS class for the button container. */
className?: string
/**
* If true, send structured=true and return structured fields.
* The onStructuredResult callback will be called instead of onResult.
*/
structured?: boolean
/** Called with structured fields when structured mode returns. */
onStructuredResult?: (fields: PromptStructure, mode: 'generate' | 'complete') => void
}
export default function MaestroButton({
prompt,
onResult,
onError,
className = '',
structured = false,
onStructuredResult,
}: Props) {
const [isMaestroing, setIsMaestroing] = useState(false)
const handleMaestro = async () => {
setIsMaestroing(true)
try {
const res = await fetch('/api/text-gen/maestro', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hint: prompt.trim(),
structured,
}),
})
if (!res.ok) {
try {
const errorData = await res.json()
throw new Error(errorData.detail || 'Maestro generation failed')
} catch (parseErr) {
if (parseErr instanceof Error && parseErr.message !== 'Maestro generation failed') throw parseErr
throw new Error('Maestro generation failed')
}
}
const data = await res.json()
if (structured && data.fields && onStructuredResult) {
onStructuredResult(data.fields as PromptStructure, data.mode)
} else {
onResult(data.prompt, data.mode)
}
} catch (err) {
onError(err instanceof Error ? err.message : 'Maestro generation failed')
} finally {
setIsMaestroing(false)
}
}
const mode = prompt.trim() ? 'complete' : 'generate'
const label = mode === 'generate'
? '♪ Maestro Mode'
: '♪ Keep Composing'
return (
<button
type="button"
onClick={handleMaestro}
disabled={isMaestroing}
className={`${className} flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-all duration-200 ${
isMaestroing
? 'bg-kithara-accent/20 text-kithara-accent animate-pulse'
: 'bg-kithara-accent/10 text-kithara-accent hover:bg-kithara-accent/25 border border-kithara-accent/30 hover:border-kithara-accent/50'
}`}
title={mode === 'generate'
? "Let Maestro write a creative prompt for you"
: "Let Maestro complete your prompt with musical details"
}
>
{isMaestroing ? (
<>
<span className="inline-block animate-spin">♩</span>
{mode === 'generate' ? 'Dreaming...' : 'Composing...'}
</>
) : (
label
)}
</button>
)
}