- New auth.py module: users table, invite_codes table, JWT auth,
password hashing (bcrypt), auth dependencies for FastAPI
- KITHARA_MODE=single|multi in .env (default: single)
- Single mode: no auth required, no login, user_id='default'
- Multi mode: full JWT auth, login/register endpoints, admin bootstrap
- Health endpoint now reports mode: {status, version, mode}
- Auth endpoints: POST /api/auth/login, /register, GET /me
- Admin endpoints: GET /admin/users, POST /admin/users,
POST /admin/invites, GET /admin/invites,
DELETE /admin/users/:id, PUT /admin/users/:id/role
- BYOK: PUT /api/auth/api-key for per-user Lyria keys
- DB migrations: user_id, shared, remixed_from columns on clips
- DB indexes for user-based and shared-content queries
- requirements.txt: added PyJWT and passlib[bcrypt]
- .env.example: documented all new config options
- New clip_favorites table (clip_id, user_id DEFAULT 'default', created_at)
- API: POST /clips/:id/favorite (toggle), GET /favorites, GET /clips/:id/favorite
- Frontend: loads favorites on mount from API, toggles via POST
- Removes localStorage dependency - favorites survive cache clears
- Ready for multi-user: just add real user_id when auth arrives
- Removed entire SavedSearch system (interface, state, localStorage, dropdown)
- Added favorites system: Set of clip IDs stored in localStorage under
kithara-favorites
- Heart icon on each track (ribbon + tile view) toggles favorite
- Heart button in toolbar filters view to favorites only
- Filled heart when favorited, outline when not
- Empty state: 'No favorites yet. Heart a track to add it here!'
- Removed 'Save this search' button from filters panel
DEFAULT_ID3_ARTIST, DEFAULT_ID3_ALBUM, DEFAULT_ID3_GENRE in .env.
Clip-level metadata always takes priority. Env defaults fill in blanks.
Current defaults: Kagamine Rin / Kithara Studio / Hardcore Techno.
Also updated .env.example with all three new vars (commented out).
- Added genre column to clips table (auto-migration)
- Genre field in DetailPanel metadata section (between Album and Comment)
- Genre saved via PATCH /api/clips/:id/metadata
- Genre embedded in ID3 TCON tag on download
- Placeholder text: 'e.g. Hardcore Techno, Phonk, Dark Ambient...'
APIC tag now embeds the track's artwork (if it exists) into the MP3
file when downloading. Supports PNG, JPEG, and WebP. Key and BPM were
already being written — this was the missing piece.
React 18 doesn't pass ref as a regular prop to function components — it
needs forwardRef. Without it, the <dialog> element never received the
ref, so showModal() had no target. Click icon, nothing happens.
This was the real bug behind 'click icon, nothing happens' — Gallery
was already using ConfirmDialog but the ref was silently null.
Uses the existing ConfirmDialog component (native <dialog> element
styled like the app). Clicking Delete Track now opens a confirmation
modal instead of instantly deleting. Also cleaned up ConfirmDialog
to use React.Ref<HTMLDialogElement> for React 19 compatibility, and
removed unused ConfirmDialog import from App.tsx.
Error messages no longer over-attribute failures to user prompt phrasing.
The Lyria API has strict/loose constraints we haven't fully mapped yet,
so blaming the user's prompt when the API silently returns no audio is
misleading. Updated messages to acknowledge the API as the source.
The prompt={prompt || Object.values(...)} was added based on a wrong
assumption about the accordion form-submit bug. The real fix was
adding type='button' to the accordion toggles. This reverts the
Generator.tsx prompt prop back to just {prompt}.
Buttons inside <form> default to type='submit'. Clicking to
collapse/expand a structured prompt field was triggering the form's
submit handler, which fires audio generation. Added explicit
type='button' to all non-submit buttons in StructuredPromptEditor.
The frontend no longer does separator/punctuation logic. Both Maestro
modes (generate and complete) now simply setPrompt(result). The LLM
is instructed to return the user's exact words + continuation, properly
joined. This is the right layer for text joining decisions.
Two fixes:
- Backend: MAESTRO_COMPLETE_PROMPT now instructs Gemini to output ONLY
the continuation text, not a rewrite. No repeating user's words.
- Frontend: complete mode appends result to existing prompt with smart
separator logic (lowercase start = space, uppercase = period+space).
generate mode still replaces the empty prompt as before.
The MAESTRO_COMPLETE_PROMPT was telling Gemini to NOT repeat the user's
text, which caused the frontend to replace the entire prompt with only
the completion. Changed the prompt to instruct Gemini to rewrite and
expand the user's idea into a complete, polished prompt that incorporates
their words naturally. This way setPrompt(result) replaces with
something the user recognizes as their own idea, expanded.
The note icon now cycles through four musical note variants during
loading: quarter note → eighth note → beamed eighths → sixteenth note.
Each variant bounces on staff lines while cycling every 800ms.
Idle state shows a static eighth note. No more generic spinner.
Note icon now always present at 14x14px (matching text size).
Static state: note sits still on staff lines.
Loading state: same icon bounces. No size jump between states.
Removed ♪ text character in favor of the SVG icon.
Replaced generic spinning character with a custom SVG eighth note
bouncing against four staff lines. Added note-bounce keyframe to
Tailwind config (0.6s ease-in-out infinite). More on-brand for a
music tool than a generic spinner.
- Added min-w-[140px] to prevent width change between states
- Border now always present (same color in both states), preventing 1px jump
- Text wrapped in truncate spans for overflow safety
- Spinning ♩ note preserved in loading state
The nginx proxy was stripping Range and If-Range headers, forcing the
browser to buffer entire MP3 files before playback could start.
Added Range header passthrough, disabled proxy_buffering for audio
streaming, and increased read timeout.
- Convert generate_waveform_svg() from sync to async
(subprocess.run -> asyncio.create_subprocess_exec)
- Add early downsampling to ~2000 samples before numpy processing
(~95% memory reduction for 120s tracks)
- Pre-generate waveform on clip completion in _generate_and_save()
- Add Cache-Control headers (public, max-age=86400) to waveform endpoint
- Preserve on-demand generation as lazy fallback
Search improvements (all 7 recommendations implemented):
1. Search all metadata fields:
- Fuzzy search now includes: title, prompt, artist, album, comment,
model, status, error_message, key, BPM, duration, structured prompt fields
- Built _searchText concatenated field for Fuse.js indexing
2. Fuzzy search via fuse.js:
- Installed fuse.js@7.0.0
- threshold: 0.4 (balanced precision/recall)
- ignoreLocation: true (matches anywhere in text)
- includeScore for ranking
3. Advanced filters panel:
- Model: All / Clip Preview / Pro
- Status: All / Ready / Generating / Failed
- Artwork: All / Has Artwork / No Artwork
- BPM Range: Min/Max number inputs
- Date Range: From/To date pickers
- Active filter chips shown below search bar
- Clear all button
4. Search term highlighting:
- HighlightText component wraps matched terms in
bg-lyria-accent/30 background
- Applied to title and prompt in both ribbon and tile views
5. Debounced input (300ms):
- useDebounce hook delays search execution
- Prevents filtering on every keystroke
- Smoother UX, less CPU usage
6. Saved searches:
- Save current search + filters with custom name
- Stored in localStorage (lyria-saved-searches)
- Load saved search to restore query + filters
- Delete saved searches
- Bookmark icon in search bar
7. Backend search endpoint:
- GET /api/clips/search?q=&model=&status=&bpm_min=&bpm_max=&date_from=&date_to=&limit=&offset=
- Full pagination support for large libraries
- Same fuzzy logic as frontend, server-side
Gallery component completely rewritten with:
- Cleaner header layout with search, filters, saved searches, view toggle
- Results count showing filtered/total
- Empty state when no matches
- Pagination with page info
Previously the Danger Zone was always expanded, unlike the Image Generation
and Text Generation sections which were collapsible.
Now:
- Danger Zone has a clickable header with warning icon + chevron
- Collapsed by default (consistent with other sections)
- Click to expand and reveal Clear Waveforms + Delete All buttons
- Chevron rotates on expand/collapse
- Red styling maintained for visual distinction
This keeps the settings modal clean and uncluttered by default.
Bug: SuggestionsModal only fetched on first open because it checked
if suggestions.length === 0 before fetching. This meant changing the
prompt and re-opening the modal showed stale suggestions.
Fix: useEffect now:
- Clears suggestions and error on every open
- Always fetches fresh suggestions when isOpen changes
- Also re-fetches when userInput changes while modal is open
- Dependency array: [isOpen, userInput]
Result: Every click of 'Get Suggestions' generates new, relevant
suggestions based on the current prompt.
Backend:
- Added artist, album, comment columns to clips DB (migration in init_db)
- New update_metadata() function in database.py
- New PATCH /api/clips/{id}/metadata endpoint
- Updated download endpoint to write ID3 tags using mutagen:
* TIT2 (Title) — from clip title
* TPE1 (Artist) — from clip artist
* TALB (Album) — from clip album
* COMM (Comment) — from clip comment
* TBPM (BPM) — from detected BPM
* TKEY (Key) — from detected key
* TIT1 (Content Group) — prompt for reference
- Added mutagen==1.47.0 to requirements.txt
- Creates temporary copy with tags, original file untouched
Frontend:
- Clip interface updated with artist, album, comment fields
- New MetadataField component in DetailPanel:
* Inline editing with click-to-edit
* Input field with lyria-accent border
* Enter to save, Escape to cancel, blur to save
* Pencil icon on hover
* Shows placeholder when empty
- App.tsx: handleUpdateMetadata() function, passed to DetailPanel
- Updates state across clips, currentClip, selectedClip
Architecture KB and Tasks KB updated.
Backend:
- New text_gen.py module with Gemini text generation client
- suggest_prompts(): generates 3 prompt suggestions from user input
- get_text_gen_config(): loads config from .env (model, API key, safety)
- get_available_models(): returns list of Gemini text models
- Safety settings: harassment, hate_speech, sexually_explicit, dangerous
Configurable via TEXT_GEN_SAFETY_* env vars (default: BLOCK_ONLY_HIGH)
- New endpoints:
* POST /api/text-gen/suggest — generates 3 prompt suggestions
* GET /api/text-gen/models — lists available text models
* GET /api/text-gen/config — returns safe config (API key masked)
* POST /api/text-gen/config — updates config in-memory
- Default model: gemini-2.5-flash
- Fallback API key: LYRIA_API_KEY if TEXT_GEN_API_KEY not set
- System prompt: expert music producer persona for rich musical detail
Frontend:
- New SuggestionsModal component for displaying generated suggestions
- Auto-fetches when opened
- Shows loading spinner, error state, or suggestion list
- Each suggestion clickable to populate the prompt field
- Hover effect: 'Click to use this prompt →'
- Generator component updated with 'Get Suggestions ✨' button
- Appears next to prompt label in both Basic and Advanced modes
- Disabled when no input present
- Clicking suggestion populates the prompt/structured fields
- SettingsModal expanded with Text Generation section (collapsible)
- Same pattern as Image Generation: model, API key status, safety filters
- Editable dropdowns with Save Changes button
- Refactored to use shared useGenSettings hook for both sections
Models Available:
- gemini-2.5-flash (GA, default) — best price-to-performance
- gemini-2.5-pro (GA) — highest quality reasoning
- gemini-3.5-flash (Preview) — next-gen Flash
- gemini-3.1-flash (Preview) — latest with thinking
Architecture KB and Tasks KB updated.
Backend:
- Added prompt_structure JSON column to clips DB (migration in init_db)
- Updated GenerateRequest model with optional prompt_structure dict
- build_prompt() method combines structured fields into single prompt:
'Generate a track with the following elements:\n\n**Style:** [style]\n...'
- create_clip() accepts and stores prompt_structure JSON
- get_clips()/get_clip() parse prompt_structure back to dict
Frontend:
- New StructuredPromptEditor component with 8 collapsible fields:
Style, Timbre, Structure, Mood, Tempo, Instrumentation, Effects, Additional
- Fields show filled/empty state with colored dots
- Expand/collapse individual fields
- 'Expand All' button
- Counter: 'X of 8 fields filled'
- Generator component updated with Basic/Advanced mode toggle (Pro only)
- Basic: single textarea (works for both models)
- Advanced: StructuredPromptEditor (Pro only)
- Mode resets to Basic when switching to Clip Preview
- DetailPanel shows structured prompt breakdown when present
- Each filled field shown in its own labeled box
- Style, Timbre, Structure, Mood, Tempo, Instrumentation, Effects, Additional
- Clip interface updated with prompt_structure field across all components
Architecture KB and Tasks KB updated.
Backend:
- New POST /api/image-gen/config endpoint to update image generation config
in-memory (applies immediately, resets on server restart)
- Accepts: model, safety_harassment, safety_hate_speech,
safety_sexually_explicit, safety_dangerous
Frontend:
- SettingsModal safety filters are now editable dropdowns (no longer disabled)
- Model selection is also editable
- Added 'Save Changes' button with visual feedback:
* Green 'Settings saved!' on success
* Red 'Failed to save' on error
* Disabled when no changes made
- Tracks edited state vs. saved state
- Note: changes apply immediately but reset on server restart
(set .env for persistence)
Fixed TypeScript issues:
- getConfigValue() properly typed to return string only
- Proper union type for config keys excluding has_api_key
Backend:
- New image_gen.py module with Gemini image generation client
- generate_artwork(): generates PNG from text prompt using Google GenAI
- get_image_gen_config(): loads config from .env (model, API key, safety)
- get_available_models(): returns list of Gemini image models
- Safety settings: harassment, hate_speech, sexually_explicit, dangerous
Configurable via IMAGE_GEN_SAFETY_* env vars (default: BLOCK_ONLY_HIGH)
- New endpoints:
* POST /api/clips/{id}/generate-artwork — generates and saves artwork
* GET /api/image-gen/models — lists available models
* GET /api/image-gen/config — returns safe config (API key masked)
- Default model: gemini-3.1-flash-image-preview
- Fallback API key: LYRIA_API_KEY if IMAGE_GEN_API_KEY not set
- Added google-genai==1.0.0 to requirements.txt
Frontend:
- New ArtworkGenerationModal component for prompt input
- Textarea with placeholder, loading spinner during generation
- Styled consistent with SettingsModal (backdrop blur, rounded, dark)
- SettingsModal expanded with Image Generation section (collapsible)
- Shows current model, API key status, safety filter levels
- Read-only display — values configured via .env
- DetailPanel: 'Generate' button added next to Upload/Change
- Passes onGenerateArtwork prop to App.tsx
- App.tsx: handles artwork generation flow, updates state on success
Architecture KB and Tasks KB updated.
DetailPanel.tsx:
- Added isPlaying and currentClipId props to track playback state
- Added onTogglePlay prop for pausing currently-playing track
- Album art click handler now smart:
* If this track IS playing → pause it (onTogglePlay)
* If this track is NOT playing → play it (onPlay)
- Hover overlay icon adapts dynamically:
* Currently playing + hover → shows PAUSE icon (⏸)
* Not playing + hover → shows PLAY icon (▶)
- Works for both artwork images and placeholder state
App.tsx:
- Passes isPlaying, currentClipId, and onTogglePlay to DetailPanel
Visual states:
- Different track in panel: ▶ on hover (click to play)
- Same track playing: ⏸ on hover (click to pause)
- Same track paused: ▶ on hover (click to resume)
DetailPanel.tsx:
- Added optional onPlay prop to interface
- Album art image now wrapped in clickable container with cursor-pointer
- Hover overlay: semi-transparent black bg with white play triangle icon
- Works for both artwork images and placeholder (treble clef) state
- Placeholder gets hover:bg-white/10 for subtle feedback
- App.tsx: passes handlePlay to DetailPanel onPlay prop
Visual feedback:
- group-hover opacity transition on overlay (0 → 1)
- Play icon: w-12 h-12 white triangle (▶) centered
- Smooth 150ms transition on overlay opacity
Player.tsx: Download button added to the left of the album art thumbnail
in the track info section (hidden on mobile). Uses window.open() to trigger
browser download. Styled as text-lyria-muted/60 base, hover to lyria-accent.
Consistent with secondary action tier styling.
Key color fix:
- Previous offset put D# major at green (~150°) instead of orange (~30°)
- New offset: (position + 8) * 30° so position 5 (D#/Eb) = 390° mod 360 = 30° orange
- Position 8 (C) = 480° mod 360 = 120° green. Adjacent positions = adjacent hues.
BPM color fix:
- Previous wrap-around math went blue→purple→red, missing cyan/green/yellow/orange
- New: simple linear interpolation 240° → 0° (long way: 240→180→120→60→0)
- 60 BPM = blue (240°), 120 BPM = green (120°), 180 BPM = red (0°)
Badge text cleanup:
- BPM: just the number (e.g., '140'), no 'BPM' text. Tooltip shows full value.
- Key: shortened format — 'D#m' for minor, 'D#' for major. Unicode flats/sharps.
Tooltip shows full 'D# major' / 'D# minor'.
- DetailPanel: same short format for consistency.
Auto-fetch analysis:
- App.tsx fetchClips() now fetches analysis for any ready clips missing BPM/key
- Runs in background after clips load, updates state silently
- No more needing to click into detail panel to see colors
New musicColors.ts utility:
- getKeyColor(): Maps musical key to Camelot wheel position → hue.
12 positions × 30° = full color wheel. Major = brighter/more saturated.
Minor = slightly darker/less saturated. Returns {bg, text, border}.
- getBPMColor(): Gradient from slow (blue, 240°) to fast (red, 0°).
Default range: 60 BPM → 180 BPM. Configurable via BPMRange.
Clamps outside range. Returns {bg, text, border}.
Gallery.tsx:
- Ribbons: BPM and key badges now use dynamic HSL colors
- Tiles: Same colored badges in tile footer
- Both use inline styles for bg/text/border
DetailPanel.tsx:
- BPM and key metadata items now show as colored badges
- Same color system as gallery badges
All badges: subtle translucent background, colored text, thin colored border.
Consistent visual language across ribbon, tile, and detail views.
Backend:
- New analysis.py module using librosa for BPM + key detection
- detect_bpm(): librosa.beat.beat_track() returns tempo
- detect_key(): Krumhansl-Schmuckler key profiles on chroma features
- analyze_clip(): combined call returning {bpm, key}
- New dependency: librosa==0.10.2 + soundfile==0.12.1
- Database migrations: bpm (REAL) and key (TEXT) columns added
- New set_analysis() function in database.py
- New endpoint: GET /api/clips/{id}/analysis
- Returns cached analysis if available
- Computes on-demand if missing, caches result
- 400 if clip not ready, 404 if no audio file
Frontend:
- Clip interface extended with bpm?: number | null, key?: string | null
- Gallery: BPM and key badges shown in ribbon and tile views
- BPM badge: lyria-accent background
- Key badge: white/5 background
- DetailPanel: BPM and key shown in metadata grid when available
- App.tsx: handleSelectClip() fetches analysis on-demand when opening
detail panel if not already cached
Docs:
- Architecture KB updated with Audio Analysis section
- Tasks KB: T-028 marked In Progress → will mark Done after commit
Reduced marquee animation duration:
- Min duration: 6s → 4s
- Per-character multiplier: 0.3s → 0.2s
A 20-character title now scrolls in ~8s instead of ~12s.
The marquee animation was working but the text was still clipped by
overflow:hidden on both the parent container and the text div itself.
Added group-hover:overflow-visible to both containers so the full
text becomes visible when the marquee animation starts on hover.
The marquee scroll effect wasn't working because 'animate-marquee'
was defined as an inline CSS class, not a Tailwind utility. Tailwind's
JIT compiler strips unknown classes, so group-hover:animate-marquee
was being removed at build time.
Fix: Added 'marquee-scroll' keyframes and 'marquee' animation to
tailwind.config.js theme.extend. Now 'animate-marquee' is a proper
Tailwind utility that works with group-hover and responsive variants.
Also removed the inline <style> block from Player.tsx since the
animation is now handled by Tailwind.