commit a2bc785b14176f1df57ccb1f8377f6b56ff99f74 Author: Kagamine Rin Date: Tue May 26 18:18:55 2026 -0400 Initial scaffold: FastAPI backend + React frontend + Docker Compose - Backend: FastAPI with SQLite, clip CRUD, health check - Frontend: React + Vite + TypeScript + Tailwind, dark theme - Components: Generator, Gallery, Player - Docker: Compose setup for backend + frontend - Architecture docs in Palace KB diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a53fb40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ + +# Node +node_modules/ +dist/ +*.log + +# Data +data/clips/*.mp3 +data/*.db +*.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0a0852 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Lyria Studio + +A webapp for generating, playing, and storing music clips using Google's Lyria music generation models. + +Built by Kagamine Rin & Ryan. + +## Quick Start + +```bash +# Backend +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cd src +uvicorn main:app --reload + +# Frontend (new terminal) +cd frontend +npm install +npm run dev +``` + +## Architecture + +- **Backend**: FastAPI (Python) — async Lyria API client, SQLite metadata, file storage +- **Frontend**: React + Vite + TypeScript + Tailwind — dark theme, audio player +- **Storage**: SQLite for clip metadata, local filesystem for MP3 files + +## API + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/health` | GET | Health check | +| `/api/generate` | POST | Start clip generation | +| `/api/clips` | GET | List all clips | +| `/api/clips/{id}` | GET | Get clip metadata | +| `/api/clips/{id}/audio` | GET | Stream MP3 | +| `/api/clips/{id}` | DELETE | Delete clip | + +## Models + +- **v0.1**: Lyria 3 Clip Preview (30s, REST) +- **v0.2**: Lyria 3 Pro (structure-aware, ~3min) +- **v0.3**: Lyria RealTime (WebSocket streaming) + +## License + +MIT — Hardcore will never die. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5a8c000 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ + +WORKDIR /app/src + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4f98195 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +httpx==0.27.0 +aiosqlite==0.20.0 +pydantic==2.9.0 +python-multipart==0.0.12 diff --git a/backend/src/database.py b/backend/src/database.py new file mode 100644 index 0000000..64d57d5 --- /dev/null +++ b/backend/src/database.py @@ -0,0 +1,80 @@ +import aiosqlite +import uuid +from datetime import datetime +from pathlib import Path + +DB_PATH = Path(__file__).parent.parent / "data" / "lyria.db" +DB_PATH.parent.mkdir(parents=True, exist_ok=True) + +INIT_SQL = """ +CREATE TABLE IF NOT EXISTS clips ( + id TEXT PRIMARY KEY, + prompt TEXT NOT NULL, + model TEXT NOT NULL, + duration_seconds INTEGER, + filename TEXT NOT NULL, + file_size_bytes INTEGER, + status TEXT DEFAULT 'generating', + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_clips_created ON clips(created_at DESC); +""" + +async def init_db(): + async with aiosqlite.connect(DB_PATH) as db: + await db.executescript(INIT_SQL) + await db.commit() + +async def create_clip(prompt: str, model: str, duration: int) -> str: + clip_id = str(uuid.uuid4()) + filename = f"{clip_id}.mp3" + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "INSERT INTO clips (id, prompt, model, duration_seconds, filename, status) VALUES (?, ?, ?, ?, ?, ?)", + (clip_id, prompt, model, duration, filename, "generating") + ) + await db.commit() + return clip_id + +async def update_clip_status(clip_id: str, status: str, file_size: int = None, error: str = None): + async with aiosqlite.connect(DB_PATH) as db: + if file_size is not None: + await db.execute( + "UPDATE clips SET status = ?, file_size_bytes = ? WHERE id = ?", + (status, file_size, clip_id) + ) + elif error is not None: + await db.execute( + "UPDATE clips SET status = ?, error_message = ? WHERE id = ?", + (status, error, clip_id) + ) + else: + await db.execute( + "UPDATE clips SET status = ? WHERE id = ?", + (status, clip_id) + ) + await db.commit() + +async def get_clips(limit: int = 50, offset: int = 0): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM clips ORDER BY created_at DESC LIMIT ? OFFSET ?", + (limit, offset) + ) as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + +async def get_clip(clip_id: str): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM clips WHERE id = ?", (clip_id,)) as cursor: + row = await cursor.fetchone() + return dict(row) if row else None + +async def delete_clip(clip_id: str): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute("DELETE FROM clips WHERE id = ?", (clip_id,)) + await db.commit() diff --git a/backend/src/main.py b/backend/src/main.py new file mode 100644 index 0000000..62faba5 --- /dev/null +++ b/backend/src/main.py @@ -0,0 +1,76 @@ +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, StreamingResponse +from pathlib import Path +import os + +from models import GenerateRequest, GenerateResponse, ClipList +from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status + +app = FastAPI(title="Lyria Studio", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +CLIPS_DIR = Path(__file__).parent.parent / "data" / "clips" +CLIPS_DIR.mkdir(parents=True, exist_ok=True) + +@app.on_event("startup") +async def startup(): + await init_db() + +@app.get("/api/health") +async def health(): + return {"status": "ok", "version": "0.1.0"} + +@app.post("/api/generate", response_model=GenerateResponse) +async def generate(request: GenerateRequest): + clip_id = await create_clip(request.prompt, request.model, request.duration) + # TODO: T-003 — integrate Lyria API client + return GenerateResponse( + id=clip_id, + status="generating", + message="Clip generation started. Check back for status." + ) + +@app.get("/api/clips", response_model=ClipList) +async def list_clips(limit: int = 50, offset: int = 0): + clips = await get_clips(limit, offset) + return ClipList(clips=clips, total=len(clips)) + +@app.get("/api/clips/{clip_id}") +async def get_clip_by_id(clip_id: str): + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + return clip + +@app.get("/api/clips/{clip_id}/audio") +async def stream_audio(clip_id: str): + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + file_path = CLIPS_DIR / clip["filename"] + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Audio file not found") + return FileResponse(file_path, media_type="audio/mpeg", filename=clip["filename"]) + +@app.delete("/api/clips/{clip_id}") +async def remove_clip(clip_id: str): + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + file_path = CLIPS_DIR / clip["filename"] + if file_path.exists(): + file_path.unlink() + await delete_clip(clip_id) + return {"deleted": True} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/src/models.py b/backend/src/models.py new file mode 100644 index 0000000..a34e7d5 --- /dev/null +++ b/backend/src/models.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +class GenerateRequest(BaseModel): + prompt: str + model: str = "lyria-3-clip-preview" + duration: int = 30 + +class GenerateResponse(BaseModel): + id: str + status: str + message: str + +class Clip(BaseModel): + id: str + prompt: str + model: str + duration_seconds: int + filename: str + file_size_bytes: Optional[int] = None + status: str + error_message: Optional[str] = None + created_at: datetime + +class ClipList(BaseModel): + clips: list[Clip] + total: int diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6106835 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8000:8000" + volumes: + - ./data:/app/data + environment: + - LYRIA_API_KEY=${LYRIA_API_KEY} + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + depends_on: + - backend + restart: unless-stopped diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..bdd2e11 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 3000 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d8470a8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Lyria Studio + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ad2c85c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 3000; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6515eae --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "lyria-studio-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.0", + "vite": "^5.4.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..3d5c2e2 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from 'react' +import Generator from './components/Generator' +import Gallery from './components/Gallery' +import Player from './components/Player' + +interface Clip { + id: string + prompt: string + model: string + duration_seconds: number + status: string + created_at: string +} + +function App() { + const [clips, setClips] = useState([]) + const [currentClip, setCurrentClip] = useState(null) + const [isPlaying, setIsPlaying] = useState(false) + + const fetchClips = async () => { + try { + const res = await fetch('/api/clips') + const data = await res.json() + setClips(data.clips) + } catch (err) { + console.error('Failed to fetch clips:', err) + } + } + + useEffect(() => { + fetchClips() + const interval = setInterval(fetchClips, 5000) + return () => clearInterval(interval) + }, []) + + const handlePlay = (clip: Clip) => { + setCurrentClip(clip) + setIsPlaying(true) + } + + const handleDelete = async (id: string) => { + try { + await fetch(`/api/clips/${id}`, { method: 'DELETE' }) + if (currentClip?.id === id) { + setCurrentClip(null) + setIsPlaying(false) + } + fetchClips() + } catch (err) { + console.error('Failed to delete clip:', err) + } + } + + return ( +
+
+
+
+ L +
+

Lyria Studio

+ v0.1 +
+
+ +
+
+ +
+
+ +
+
+ + {currentClip && ( + setIsPlaying(!isPlaying)} + onClose={() => { + setCurrentClip(null) + setIsPlaying(false) + }} + /> + )} +
+ ) +} + +export default App diff --git a/frontend/src/components/Gallery.tsx b/frontend/src/components/Gallery.tsx new file mode 100644 index 0000000..800e2cf --- /dev/null +++ b/frontend/src/components/Gallery.tsx @@ -0,0 +1,90 @@ +interface Clip { + id: string + prompt: string + model: string + duration_seconds: number + status: string + created_at: string +} + +interface Props { + clips: Clip[] + onPlay: (clip: Clip) => void + onDelete: (id: string) => void + currentClipId?: string +} + +export default function Gallery({ clips, onPlay, onDelete, currentClipId }: Props) { + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + if (clips.length === 0) { + return ( +
+

No clips yet. Generate your first one!

+
+ ) + } + + return ( +
+
+

+ Gallery ({clips.length}) +

+
+ +
+ {clips.map((clip) => ( +
+ + +
+

{clip.prompt}

+
+ + {clip.status} + + {clip.duration_seconds}s + {formatDate(clip.created_at)} +
+
+ + +
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/Generator.tsx b/frontend/src/components/Generator.tsx new file mode 100644 index 0000000..9522326 --- /dev/null +++ b/frontend/src/components/Generator.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react' + +interface Props { + onGenerate: () => void +} + +export default function Generator({ onGenerate }: Props) { + const [prompt, setPrompt] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!prompt.trim()) return + + setIsLoading(true) + setError('') + + try { + const res = await fetch('/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: prompt.trim(), + model: 'lyria-3-clip-preview', + duration: 30 + }) + }) + + if (!res.ok) throw new Error('Generation failed') + + setPrompt('') + onGenerate() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong') + } finally { + setIsLoading(false) + } + } + + return ( +
+

+ Generate +

+ +
+
+ +