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.
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.
Player.tsx:
- Track name now scrolls horizontally when hovered (marquee effect)
- Uses CSS @keyframes animation with duplicated text for seamless loop
- Animation duration scales with text length (min 6s, ~0.3s per char)
- Default state: truncate with ellipsis. Hover: whitespace-nowrap + animate
- Wrapped player in React fragment with inline <style> for keyframes
- Uses Tailwind group-hover to trigger animation only on hover
DetailPanel.tsx:
- Replaced window.prompt with native inline editing
- Click title text or 'Rename' button to enter edit mode
- Input field with lyria-accent border, focus ring, and placeholder
- Auto-focus + select-all on enter edit mode (useEffect)
- Enter to save, Escape to cancel, blur to save
- Title text has hover:bg-white/5 for visual feedback
- Non-editing title is clickable (cursor-pointer, title tooltip)
- Uses useState for edit mode and draft text, useRef for input focus
App.tsx main element now has pb-24 (96px bottom padding) so the
gallery content always has breathing room above the fixed player bar.
Prevents content from sitting flush against the player controls.
Player.tsx track info section now shows a 32x32 album art thumbnail
left of the track name. Uses the clip's artwork endpoint with an
onError fallback to the treble clef placeholder (same SVG text
approach as gallery placeholders). Hidden on mobile (sm breakpoint).
Layout: flex row with 10px gap between thumbnail and title text.
Player.tsx updates:
- Repeat button: ISO repeat indefinitely icon with glowing toggle state.
When active: lyria-accent color, bg-lyria-accent/10, orange glow shadow.
When inactive: muted gray, hover to white. Toggles audio.loop.
- Skip to beginning: Now uses ISO skip-backwards icon (filled, two bars
pointing left) instead of circular arrow. Title: 'Skip to beginning'.
- Track end handler respects repeat state — if repeating, stays playing
and loops; if not, pauses as before.
- New state: isRepeating (useState), toggleRepeat function sets both
React state and audioRef.current.loop.
Player.tsx completely rewritten with professional controls:
- Play/Pause: large circular button with play/pause icons
- Stop: square icon, resets audio to 0 and pauses
- Restart: circular arrow, jumps to beginning and plays
- Time display: elapsed / total (M:SS format, tabular-nums)
- Scrubber: clickable progress bar with drag support
- Visual progress fill (lyria-accent)
- White scrubber handle on hover
- Mouse drag anywhere on page while holding
- Volume: mute toggle + range slider (0-1, step 0.01)
- Three volume icons: full, half, muted
- Custom styled range input with white thumb
- Track info: title or prompt (hidden on mobile)
- Close button: X icon
- All buttons have title tooltips
- Uses useRef for audio element, useState for UI state
- useCallback for seek handler, useEffect for drag events
- Responsive: track info hidden on small screens
- Both danger zone rows now use flex items-start gap-4 for consistent
text+button alignment with proper breathing room
- Added shrink-0 to buttons so they don't compress
- Added whitespace-nowrap to Delete All button so text doesn't wrap
- Shortened confirm button text to just 'Confirm' (was 'Confirm Delete')
- Moved confirmation warning inline under the description text instead
of as a separate block below, keeping the row compact
- Shortened description text: 'Remove N clips. Cannot be undone.'
Backend:
- POST /api/admin/clear-waveforms — deletes all cached waveform SVGs
- POST /api/admin/delete-all-clips — nuclear option: deletes all clips,
MP3s, artwork, and waveforms. Returns deleted count.
- database.py: delete_all_clips() for bulk DB wipe
- Clip deletion cascade-removes waveform SVGs
Frontend:
- SettingsModal.tsx: center-stage modal with backdrop blur (bg-black/60
backdrop-blur-sm). Danger zone with red-styled border and header.
- Two-step confirmation for Delete All (click once to arm, click again
to confirm). Shows clip count dynamically.
- Gear icon trigger in header bar
- App.tsx: handleClearWaveforms + handleDeleteAll handlers
Docs:
- Architecture KB updated with album art, waveform, settings modal sections
- Tasks KB updated with T-021 (waveform) and TASK-001 (album art) marked done
- T-026 (settings modal) added to backlog
Backend:
- Install ffmpeg + numpy in Docker image
- New waveform.py: FFT-based SVG generator with 64 frequency bands
- GET /api/clips/{id}/waveform — generates on demand, caches to /data/waveforms/
- DELETE /api/clips/{id} now cascade-removes waveform SVG
- Warm amber-to-orange HSL gradient (25° to 35° hue)
- Mirrored bars: top full opacity, bottom 60% opacity
Frontend:
- DetailPanel shows waveform image beneath album art (80px height)
- Only shown for clips with status='ready'
Storage: /data/waveforms/ persisted via existing ./data volume mount
Ribbon view was missing the onClick handler that opens the detail panel.
Tile view had it; ribbon view did not. Now both views open the slide-out
panel when clicking a clip row/card.
Ribbon view artwork hover now shows only play button (larger, cleaner).
Upload art button moved to action row alongside rename/delete.
Order: rename → upload art → delete. All buttons 4x4, consistent spacing.
Replaces broken SVG path with Unicode U+1D11E treble clef symbol
rendered via SVG text element. Applied to all three locations:
- Gallery ribbon: small treble clef (5x5)
- Gallery tile: medium treble clef (10x10)
- DetailPanel: large treble clef (14x14)
Album art placeholders now show a treble clef (Rin's signature)
instead of generic icons. Applied to all three locations:
- Gallery ribbon: small treble clef (5x5)
- Gallery tile: medium treble clef (10x10)
- DetailPanel: large treble clef (14x14) with 'No album art' label
The Lyria API returns a candidates[].content.parts[] array where:
- parts[0] = text metadata (e.g. '<instrumental>')
- parts[1] = inlineData with the actual audio payload
Previous code only checked parts[0] for inlineData, which always failed
with 'No audio data in response'. Fixed by iterating all parts to find
the inlineData entry.
Successfully generated first real audio clip (lo-fi hip hop, 30s, 728KB MP3).
Also includes: .env.example, requirements updates, docker-compose fixes,
frontend vite config, logger setup.