diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8950b5f --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# ───────────────────────────────────────────────────────────────────────────── +# MelodyMuse — server environment +# +# The Node server (`server.mjs`) reads these from `process.env`. Pass them in +# however you run the server: +# +# • Bare-metal: LLM_ENDPOINT=… LLM_API_KEY=… node server.mjs +# • Docker: -e LLM_ENDPOINT=… -e LLM_API_KEY=… +# • docker-compose: put them in a `.env` file next to compose.yml +# • Jannik-Cloud: AGE-encrypted `.env` generated by `generate-env.sh` +# +# `LLM_ENDPOINT` and `LLM_API_KEY` are required — the server refuses to start +# without them. The rest have sensible defaults. +# ───────────────────────────────────────────────────────────────────────────── + +# OpenAI-compatible base URL, e.g. https://api.minimax.chat/v1 +LLM_ENDPOINT=https://api.minimax.chat/v1 + +# Your provider's secret key +LLM_API_KEY= + +# Model name (default: MiniMax-M3) +LLM_MODEL=MiniMax-M3 + +# Listen port (default: 3000) +PORT=3000 + +# CORS origin. "*" allows any origin (fine for a single-user personal app). +# Set to a specific origin like "https://melodymuse.orfel.de" to lock it down. +CORS_ORIGIN=* + +# ───────────────────────────────────────────────────────────────────────────── +# Vite dev environment (only used by `npm run dev`, not by the server) +# ───────────────────────────────────────────────────────────────────────────── + +# Leave empty to use the dev proxy (see vite.config.ts). Set this if you want +# the browser to talk to a server on a different origin. +# VITE_API_BASE_URL=http://localhost:3000 diff --git a/README.md b/README.md index d57a353..1e31b72 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,326 @@ # MelodyMuse -Generate all assets needed for a Suno AI song from a free-text description — +Generate every asset you need for a Suno AI song from a free-text idea — titles, lyrics, style prompt, video prompts (Abstract / Cinematic / Hybrid), -and a YouTube description, then download everything as a ZIP. +and a YouTube description — then download everything as a single ZIP. -The app runs **entirely in your browser** as a single static SPA. There is no -backend, no database, no third-party services: the only network calls are -direct `fetch` requests from your browser to the AI provider's -OpenAI-compatible `/chat/completions` endpoint, using credentials that you -paste into the Settings page. Those credentials are kept in this browser's -`localStorage`. +The app runs as a single Node.js process that serves both the built SPA and +the API the SPA calls. Your LLM provider's API key lives on the **server** +(env var), never in the browser. -## Features +``` +[Browser] ──► [Node server :3000] ──► [LLM provider] + (no key) (holds the key) +``` -- 🎵 **All 5 Suno assets in one click** — titles, lyrics, style, video prompts, YouTube description -- ✏️ **Inline editing** — every generated field is editable; revert any edit with one click -- 🔄 **Per-section regeneration** — regenerate just one section; the rest of the song is passed as context for consistency -- ⏱ **Elapsed-time counter** + ❌ **Cancel** button for long generations -- 🕘 **Recent generations** — last 6 are kept in localStorage; one click to reload -- ⌨️ **Keyboard shortcut** — `Cmd/Ctrl + Enter` to generate -- 🎲 **Try an example** — fill the input with a random sample idea -- 🌗 **Dark / light mode** — toggle in the header, persisted -- 💾 **Standalone** — no backend, no signup, no telemetry +--- -## Tech stack +## Table of contents -- React 18 + TypeScript + Vite -- Tailwind CSS (dark by default, light theme via `html.light`) -- JSZip for client-side ZIP generation -- `react-router-dom` for the two routes (`/`, `/settings`) +1. [Quick start](#quick-start) +2. [How to use the app](#how-to-use-the-app) + - [The main page](#the-main-page) + - [The five result cards](#the-five-result-cards) + - [The settings page](#the-settings-page) + - [Keyboard shortcuts](#keyboard-shortcuts) +3. [Features in depth](#features-in-depth) + - [Surprise me / Go crazy (style buttons)](#surprise-me--go-crazy-style-buttons) + - [Regenerate one section](#regenerate-one-section) + - [Edit, then Revert](#edit-then-revert) + - [Recent generations](#recent-generations) + - [Download the ZIP](#download-the-zip) + - [Cancel a long generation](#cancel-a-long-generation) +4. [Tips & tricks](#tips--tricks) +5. [Troubleshooting](#troubleshooting) +6. [For developers](#for-developers) -## Repository layout +--- + +## Quick start + +You need **Node.js 18+** on your machine (or a server you can deploy to). + +```sh +# 1. Clone +git clone https://git.orfel.de/Jannik/MelodyMuse.git +cd MelodyMuse + +# 2. Install +npm install + +# 3. Tell the server where your LLM lives and which key to use +export LLM_ENDPOINT="https://api.minimax.chat/v1" +export LLM_API_KEY="sk-…" +# Optional: +export LLM_MODEL="MiniMax-M3" # default + +# 4. Run the dev server (two terminals — see below) +``` + +### Dev workflow (two terminals) + +```sh +# Terminal 1 — the API/proxy server (port 3000) +npm run dev:server + +# Terminal 2 — the Vite dev server (port 5173, hot reload) +npm run dev +``` + +Open . The Vite dev server proxies `/api/*` to the +Node server on `:3000` (see `vite.config.ts`), so the SPA always talks to +the same origin. + +### Production + +```sh +npm run build # writes the static SPA to dist/ +npm start # node server.mjs, serves dist/ + /api/* +``` + +Then put a reverse proxy (Caddy, nginx, …) in front of `localhost:3000`. + +### Docker / Jannik-Cloud + +Reference files are in [`deploy/`](./deploy). See +[`deploy/README.md`](./deploy/README.md) for the full deploy guide. + +--- + +## How to use the app + +### The main page + +Two columns on desktop, stacked on mobile. + +**Left — the input panel** + +- **Music idea** — describe what you want. The more concrete, the better the + result. Press ⌘/Ctrl + Enter to generate. +- **Try an example** — fills the input with a random starter idea. +- **Options** — click to expand: + - **Language** — lyrics language. Choose "Other" to type a custom one. + - **Music style** — an optional style description. Two buttons: + - **Surprise me** — generates a normal, Suno-friendly style. + - **Go crazy** — generates an unusual genre mashup. + - **Mood** *(optional)* — an emotional hint, e.g. "melancholic". + - **Vocals** — 🎤 Vocals or 🎹 Instrumental. +- **Generate Song Assets** — main button. Spinner + elapsed time during + generation. **Cancel generation** button appears next to it. +- **Recent generations** (below the form) — last 6 saved generations. + +**Right — the results panel** + +Shows the five result cards once a generation completes. Each card is +editable and individually regenerable. The sticky results header has a +"Regenerate All" button. Once you've picked a title, the **Download ZIP** +bar appears at the bottom of the viewport. + +### The five result cards + +| Card | What it does | Editable? | Regenerate? | +|---|---|---|---| +| 🏷️ **Song Titles** | 3 short title suggestions. Click to select. | select only | yes | +| 📝 **Lyrics** | Full lyrics with Suno section tags. | yes | yes | +| 🎵 **Style** | Style prompt **and** negative style. | yes | yes (both at once) | +| 🎬 **Video Prompts** | Abstract / Cinematic / Hybrid tabs. | yes | yes | +| 📺 **YouTube Description** | Full description with embedded lyrics + hashtags. | yes | yes | + +Each editable card has a **Revert** button (top-right) that appears the +moment you change the value. Click it to restore the last generated version. + +### The settings page + +Click **Settings** (under the generate button) or navigate to `/settings`. + +- **Server status** — green check if the API server is reachable and + configured. Click **Re-check** to refresh. Shows the model name and + endpoint so you know which LLM the server is using. +- **Local data** — see what's stored in your browser (`melodymuse-history` + for recent generations, `melodymuse-theme` for the theme). You can + clear individual buckets or everything. +- **Theme** — toggle the moon/sun icon in the header. + +The API key is **not** here — it lives on the server in the `LLM_API_KEY` +environment variable. + +### Keyboard shortcuts + +| Key | Action | +|---|---| +| ⌘/Ctrl + Enter in the idea textarea | Generate | +| Esc while a generation is in flight | Cancel it | + +--- + +## Features in depth + +### Surprise me / Go crazy (style buttons) + +Both buttons are in the **Options → Music style** row. + +| Button | What the model is told | +|---|---| +| ✨ **Surprise me** | "Generate ONE short, cohesive, production-ready Suno style description. Comma-separated keywords. Max 25 words." | +| 🔥 **Go crazy** | "Generate ONE short style description that DELIBERATELY combines genres/eras/instruments that don't normally mix. Still parseable by Suno. Max 25 words." | + +When you click a button: +1. The button shows a spinner. +2. The browser calls `POST /api/style/random` on the server. +3. The server calls the LLM with a small dedicated system prompt. +4. The returned one-line style description replaces the value in the + **Music style** textarea. +5. The spinner stops. + +You can keep clicking — each click fetches a new style. The field is +overwritten, never appended. Both buttons are disabled while a request is +in flight, so you can't double-fire. + +If the field has a value when you click **Generate Song Assets**, that value +is sent to the model as a `style_hint` and used as the basis for the full +`style` field in the output. + +### Regenerate one section + +Every card has a **Regenerate** button in its header. + +- Click it to ask the model to rewrite that section **only**. +- The rest of the current song is passed as `context` so the new section + stays consistent with the existing lyrics, mood, and vocabulary. +- Only that card's spinner is active while it runs; the other cards stay + fully usable. + +### Edit, then Revert + +Every editable card compares its current value against the last generated +value. If they differ, a **Revert** button appears in the card header. +Click it to snap back to the last generated version. + +This is great for "I'll tweak this one line and see if I like it better" +without losing the original. + +### Recent generations + +The last 6 successful generations are saved in `localStorage` under +`melodymuse-history`. Click any entry to restore both the **input form +values** AND the full **generated assets** — handy for comparing two +generations of the same idea. + +Individual entries have an ✕ button to remove them. There's a **Clear all** +button at the bottom of the history panel. + +### Download the ZIP + +Once you've picked a title (the first one is auto-selected on generation), +a sticky bar appears at the bottom of the viewport showing +`📁 [Title].zip` and a **Download ZIP** button. Click it to download a ZIP +with four files: + +| File | Contents | +|---|---| +| `Style.txt` | `STYLE PROMPT: …` + `NEGATIVE STYLE: …` | +| `Text.txt` | The full lyrics | +| `Videodescription.txt` | The YouTube description | +| `Videoprompt.txt` | All three video prompts in order (Abstract, Cinematic, Hybrid), each with the negative line and tool recommendation | + +The filename is the selected title, sanitized: spaces become `_`, anything +that's not a letter, digit, underscore, or dash is removed. If the title +sanitizes to an empty string, the file is named `song.zip`. + +### Cancel a long generation + +While a generation is running: +- A **Cancel generation** button appears below the main generate button. +- A **Cancel** button appears in the sticky results header (top right of + the right panel). +- The Generate button shows a live elapsed-time counter + (e.g. `Generating… 8s`). +- Esc also works. + +Cancellation aborts the in-flight HTTP request. The server also aborts +its upstream request to the LLM, so no tokens are wasted. + +--- + +## Tips & tricks + +- **Be specific in the idea.** "Melancholic lo-fi house beat for a rainy + Sunday morning" produces better results than "make a song". Mood, + tempo, era, setting, instrumentation — all help. +- **Use Surprise me to break out of a rut.** Click it 3–4 times; one of + the styles will spark a new direction. +- **Use Go crazy for instant novelty.** "Baroque chamber orchestra meets + dubstep" might be exactly the brief you needed. +- **Style hint + surprise = best of both.** Click Surprise me, then tweak + the words slightly before generating. The model uses your edit as the + basis for the full 120-word style description. +- **Revert before regenerating.** If you edited a card and the edit isn't + quite right, click Revert to restore the last generated version, then + click Regenerate to get a fresh alternative. +- **History is your scratch pad.** If you generate 6 variations, none of + them is lost — pick the best from history. + +--- + +## Troubleshooting + +### "Server offline" / "Server unreachable" + +The SPA can't reach the Node server. Check: +- The server is running (`npm start` or `node server.mjs`). +- `LLM_ENDPOINT` and `LLM_API_KEY` are set in the server's env. +- Open — it should return + `{ "ok": true, ... }`. + +### "Provider returned 401 / 403" + +The `LLM_API_KEY` is wrong, expired, or doesn't have access to the model. +Update the env var on the server and restart. + +### "Provider returned 404" + +Either the `LLM_ENDPOINT` is wrong, the path `/chat/completions` doesn't +exist at that URL, or the model name (`LLM_MODEL`) doesn't exist for that +provider. + +### "Could not reach ${url}" with `fetch failed` + +The server can't reach the LLM provider. If you're running the server in +a container, check that it has network access to the provider. Some +providers block known cloud-IP ranges — check the provider's allow-list. + +### The model returns prose instead of JSON + +The system prompt asks for JSON only. If a model ignores that, the +server's `extractJson` tries three increasingly lenient fallbacks. If all +fail you'll see a clear error toast — re-try the generation, or try a +different model. + +### "Storage quota exceeded" toast + +Your `localStorage` is full (a typical cap is 5–10 MB). Open Settings → +Local data → Clear all local data, or just clear recent generations. + +### I can't get a clean style out of "Go crazy" + +The prompt is intentionally permissive — the model is told to "deliberately +break conventions". If you get a style that Suno rejects, just click the +button again. + +--- + +## For developers + +### Project layout ``` MelodyMuse/ -├── src/ +├── src/ # React SPA │ ├── App.tsx │ ├── main.tsx -│ ├── index.css # Tailwind layers + design-system components -│ ├── components/ # UI building blocks -│ │ ├── cards/ # The five result cards -│ │ ├── ConfigBanner.tsx +│ ├── index.css # Tailwind + design tokens +│ ├── components/ # UI primitives +│ │ ├── cards/ # The 5 result cards │ │ ├── CopyButton.tsx │ │ ├── EqualizerIcon.tsx │ │ ├── Footer.tsx @@ -51,100 +331,115 @@ MelodyMuse/ │ │ ├── SkeletonCard.tsx │ │ ├── StickyZipBar.tsx │ │ └── ThemeToggle.tsx -│ ├── pages/ # HomePage, SettingsPage -│ ├── lib/ -│ │ ├── history.ts # recent-generations persistence -│ │ ├── llm.ts # direct fetch → provider, JSON extraction, config persistence -│ │ ├── prompts.ts # system + user prompt construction -│ │ ├── theme.ts # dark/light mode -│ │ ├── toast.tsx # toast context -│ │ ├── types.ts # shared TypeScript types + SECTION_LABELS -│ │ ├── useAutoHeight.ts # shared textarea auto-grow hook -│ │ ├── useElapsed.ts # shared "X seconds elapsed" hook -│ │ └── zip.ts # JSZip layout -│ └── vite-env.d.ts +│ ├── pages/ +│ │ ├── HomePage.tsx +│ │ └── SettingsPage.tsx +│ └── lib/ +│ ├── history.ts # recent generations persistence +│ ├── llm.ts # browser → server client (thin) +│ ├── theme.ts # dark/light + localStorage +│ ├── toast.tsx # toast context +│ ├── types.ts # shared TS types +│ ├── useAutoHeight.ts # textarea auto-grow hook +│ ├── useElapsed.ts # elapsed-seconds hook +│ └── zip.ts # JSZip layout +├── server/ +│ └── prompts.mjs # system + user prompt construction +├── server.mjs # ★ the runtime — serves SPA + /api/* +├── deploy/ # reference Dockerfile, compose, caddy, env script ├── public/favicon.svg ├── index.html ├── tailwind.config.js ├── vite.config.ts ├── tsconfig*.json -└── package.json +├── package.json +└── README.md ``` -## Getting started +### Architecture -1. **Install dependencies** +The browser only ever talks to one server: the Node process in +`server.mjs`. That server: +- Serves the built SPA from `dist/` +- Exposes three JSON endpoints: + - `GET /api/health` — health check + - `POST /api/generate` — main generation + - `POST /api/style/random` — single-line Suno style +- Holds the LLM credentials in `process.env` - ```sh - npm install - ``` +The browser-side code is a thin wrapper around `fetch`. The system +prompts, JSON extraction, and shape validation all live on the server in +`server/prompts.mjs` and `server.mjs`. The browser never sees the +provider, the key, or the prompts. -2. **Start the dev server** +### Scripts - ```sh - npm run dev - ``` +| Command | What it does | +|---|---| +| `npm run dev` | Vite dev server (port 5173) with HMR. Proxies `/api/*` to `:3000`. | +| `npm run dev:server` | The Node server in dev (no build step). | +| `npm run build` | Type-check + build the SPA to `dist/`. | +| `npm start` | Run the Node server (uses the existing `dist/`). | +| `npm run preview` | Vite preview server (no HMR). | +| `npm run lint` | Type-check only. | - Open in your browser. +### Environment variables (server) -3. **Open Settings** and fill in: +| Var | Required | Default | Notes | +|---|---|---|---| +| `LLM_ENDPOINT` | ✅ | — | e.g. `https://api.minimax.chat/v1` | +| `LLM_API_KEY` | ✅ | — | Provider secret | +| `LLM_MODEL` | | `MiniMax-M3` | | +| `PORT` | | `3000` | | +| `CORS_ORIGIN` | | `*` | Lock this down in production | - - **API Endpoint URL** — e.g. `https://api.minimax.chat/v1` - - **API Key** — your provider's secret key - - **Model Name** — e.g. `MiniMax-M3` +### Environment variables (Vite, dev only) - Click **Save Configuration**, then **Test Connection** to confirm - everything is wired up. **Test Connection** uses your unsaved form - values — your changes are only persisted when you click **Save**. +| Var | Default | Notes | +|---|---|---| +| `VITE_API_BASE_URL` | empty | Override the API base URL. Leave empty in dev (Vite's proxy handles it) and in same-origin production deployments. | -4. **Generate**. Return to the home page, type a music idea, click - **Generate Song Assets** (or press `Cmd/Ctrl + Enter`). +### API contracts -## Build for production +`POST /api/generate` — body: -```sh -npm run build +```json +{ + "input": "user's music idea (required)", + "language": "English", + "mood": "optional", + "style_hint": "optional — short style description", + "vocals": "vocals | instrumental", + "section": "all | lyrics | style | titles | video_prompts | youtube_description", + "context": { /* full or partial SongAssets, used for partial regen */ } +} ``` -Outputs static assets in `dist/`. The `dist/` folder is a normal SPA — serve -it from any static host (GitHub Pages, Netlify, Vercel, `python -m http.server`, -…). +`POST /api/style/random` — body: -> ⚠️ **Heads up about deployment.** Because the API key is stored in the -> browser's `localStorage`, you should not serve a public deployment of this -> app and use it with a real key on a shared device. For personal/local use -> this is fine. +```json +{ "mode": "normal | crazy" } +``` -## CORS +Response: `{ "style": "…", "mode": "…" }`. -The app makes direct cross-origin requests from the browser to your -provider. If your provider does not send the right `Access-Control-Allow-*` -headers for your origin, the request will fail with a CORS error. The -generated error message will explicitly call this out. Workarounds: +`GET /api/health` — response: -- Pick a provider/endpoint that already permits browser CORS (most managed - OpenAI-compatible services do). -- Run the app on the same origin as the API (i.e. front it with a tiny - proxy). -- Use a CORS-permissive browser extension during local development. +```json +{ "ok": true, "llm_configured": true, "model": "MiniMax-M3", "endpoint": "https://…" } +``` -## Provider format +### Security notes -The configured endpoint must expose an OpenAI-compatible -`POST {api_endpoint}/chat/completions` route that accepts -`{ model, messages, max_tokens }` and returns -`{ choices: [{ message: { content } }] }`. - -## Security notes - -- The API key is held in `localStorage` and is only sent to the endpoint you - configure. No analytics, no telemetry, no third-party calls. -- Three localStorage keys are used: - - `melodymuse-config` — `{ api_endpoint, api_key, model_name }` - - `melodymuse-history` — the last 6 generations (input + assets) - - `melodymuse-theme` — `'dark' | 'light'` -- You can clear them at any time from your browser's devtools - (Application → Local Storage). +- The API key is held by the Node process via `process.env`. It's never + sent to the browser in any response, not even in the health check. +- The `localStorage` keys the SPA writes: + - `melodymuse-history` — last 6 generations + - `melodymuse-theme` — `'dark'` or `'light'` + - `melodymuse-config` — reserved, currently unused (kept for future + client-side server-URL override) +- You can wipe any of them from Settings → Local data, or programmatically + with the browser's DevTools. ## License diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..4f47401 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,43 @@ +# Multi-stage Dockerfile for MelodyMuse. +# +# This file lives in the MelodyMuse repo under `deploy/` as a reference. To +# add MelodyMuse to a Docker-Compose deployment (including your Jannik-Cloud +# stack), copy this file to your service directory and reference it from +# docker-compose.yml — see `docker-compose.example.yml`. + +# Stage 1 — build the SPA +FROM node:20-alpine AS builder + +RUN apk add --no-cache git + +# Override these at build time if you fork the repo. +ARG REPO_URL=https://git.orfel.de/Jannik/MelodyMuse.git +ARG BRANCH=main + +WORKDIR /build +RUN git clone --depth 1 --branch ${BRANCH} ${REPO_URL} . +RUN npm ci +RUN npm run build + +# Stage 2 — runtime: just Node + the built assets + the proxy server +FROM node:20-alpine + +WORKDIR /app +COPY --from=builder /build/dist ./dist +COPY --from=builder /build/server.mjs ./server.mjs +COPY --from=builder /build/server ./server +COPY --from=builder /build/package.json ./package.json + +# server.mjs uses only Node's built-ins — no production deps to install. +# Install nothing here; the image stays small. + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +# Quick healthcheck so Docker / your orchestrator can detect a broken boot. +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:3000/api/health || exit 1 + +CMD ["node", "server.mjs"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..e33810a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,78 @@ +# Deploying MelodyMuse + +The files in this folder are **reference templates**. MelodyMuse is a single +Node.js server (`server.mjs` in the repo root) that serves the built SPA and +proxies LLM calls. To deploy it you can: + +- Run `node server.mjs` directly (after `npm run build`) +- Use the included `Dockerfile` + `docker-compose.yml` +- Copy these into your Jannik-Cloud `services/melodymuse/` directory + +## Required environment variables + +| Var | Required? | Default | Notes | +|---|---|---|---| +| `LLM_ENDPOINT` | **yes** | — | OpenAI-compatible base URL, e.g. `https://api.minimax.chat/v1` | +| `LLM_API_KEY` | **yes** | — | Provider secret key. **Never** set this in the browser. | +| `LLM_MODEL` | no | `MiniMax-M3` | Model name | +| `PORT` | no | `3000` | Listen port | +| `CORS_ORIGIN` | no | `*` | Set to a specific origin in production if you split SPA and server | + +The server refuses to start if `LLM_ENDPOINT` or `LLM_API_KEY` is missing. + +## Option 1 — Bare Node + +```sh +# Clone, build, run +git clone https://git.orfel.de/Jannik/MelodyMuse.git +cd MelodyMuse +npm ci +npm run build + +# Set the secrets and start +export LLM_ENDPOINT=https://api.minimax.chat/v1 +export LLM_API_KEY=sk-... +npm start +``` + +## Option 2 — Docker + +```sh +# Build +docker build -f deploy/Dockerfile -t melodymuse . + +# Run +docker run --rm -p 3000:3000 \ + -e LLM_ENDPOINT=https://api.minimax.chat/v1 \ + -e LLM_API_KEY=sk-... \ + melodymuse +``` + +## Option 3 — Add to Jannik-Cloud + +1. Create `services/melodymuse/` in your Jannik-Cloud repo. +2. Copy the four files from this folder into it: + - `Dockerfile` + - `docker-compose.yml` (rename from `docker-compose.example.yml`) + - `melodymuse.caddy` + - `generate-env.sh` +3. `touch services/melodymuse/service.enabled` +4. `bash services/melodymuse/generate-env.sh` — answer the prompts. +5. Commit `services/melodymuse/.env.age` and the four non-secret files. +6. `sudo bash /opt/Jannik-Cloud/deploy_script.sh` — Caddy will pick up + `melodymuse.orfel.de` and route it to the container. + +## Endpoints exposed by the server + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/api/health` | Health check. Returns `{ ok, llm_configured, model, endpoint }`. | +| `POST` | `/api/generate` | Main generation. Body matches the `GenerateRequest` shape. | +| `POST` | `/api/style/random` | Returns a one-line Suno style. Body: `{ mode: "normal" \| "crazy" }`. | +| `GET` | everything else | Serves the built SPA from `dist/`, with SPA fallback to `index.html`. | + +## Image footprint + +The runtime image is `node:20-alpine` with only `server.mjs` and the +built SPA. No production `npm install` (server.mjs uses only Node +built-ins). Total image size is roughly 200 MB. diff --git a/deploy/docker-compose.example.yml b/deploy/docker-compose.example.yml new file mode 100644 index 0000000..92b6bdf --- /dev/null +++ b/deploy/docker-compose.example.yml @@ -0,0 +1,34 @@ +# Example docker-compose snippet for MelodyMuse. +# +# Drop this into your service directory (e.g. `services/melodymuse/` in a +# Jannik-Cloud-style stack) and rename to `docker-compose.yml`. Pair it with +# `Dockerfile`, `melodymuse.caddy`, and `generate-env.sh` from the same folder. + +services: + melodymuse: + build: + context: . + dockerfile: Dockerfile + image: melodymuse:latest + container_name: melodymuse + restart: unless-stopped + ports: + # Only loopback — let your reverse proxy (Caddy) handle public traffic. + - "127.0.0.1:3000:3000" + environment: + LLM_ENDPOINT: ${LLM_ENDPOINT} + LLM_API_KEY: ${LLM_API_KEY} + LLM_MODEL: ${LLM_MODEL:-MiniMax-M3} + PORT: ${PORT:-3000} + CORS_ORIGIN: ${CORS_ORIGIN:-*} + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + networks: + - jannik-cloud-net + +networks: + jannik-cloud-net: + external: true diff --git a/deploy/generate-env.sh b/deploy/generate-env.sh new file mode 100644 index 0000000..bfeae03 --- /dev/null +++ b/deploy/generate-env.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# generate-env.sh — create the MelodyMuse `.env` (and AGE-encrypt it). +# +# Usage: bash generate-env.sh +# +# Customize LLM_ENDPOINT / LLM_MODEL below before running. The script prompts +# for the LLM_API_KEY so it doesn't end up in your shell history. + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +AGE_PUB_KEY="${REPO_ROOT}/keys/age-public-key.txt" + +# ─── Customize these for your deployment ───────────────────────────────────── +LLM_ENDPOINT_DEFAULT="https://api.minimax.chat/v1" +LLM_MODEL_DEFAULT="MiniMax-M3" +# ────────────────────────────────────────────────────────────────────────────── + +echo "LLM endpoint [${LLM_ENDPOINT_DEFAULT}]:" +read -r LLM_ENDPOINT +LLM_ENDPOINT="${LLM_ENDPOINT:-$LLM_ENDPOINT_DEFAULT}" + +echo "LLM model [${LLM_MODEL_DEFAULT}]:" +read -r LLM_MODEL +LLM_MODEL="${LLM_MODEL:-$LLM_MODEL_DEFAULT}" + +echo "LLM API key (input is hidden):" +read -rs LLM_API_KEY +echo "" + +if [[ -z "${LLM_API_KEY}" ]]; then + echo "ERROR: LLM_API_KEY is required." + exit 1 +fi + +cat > "${SCRIPT_DIR}/.env" < -o .env.age .env" +fi diff --git a/deploy/melodymuse.caddy b/deploy/melodymuse.caddy new file mode 100644 index 0000000..42989f4 --- /dev/null +++ b/deploy/melodymuse.caddy @@ -0,0 +1,8 @@ +# Caddy reverse proxy for MelodyMuse. +# Drop this into your service directory as `melodymuse.caddy` — your +# Caddyfile (or `caddy` Docker image) will pick it up automatically. + +melodymuse.orfel.de { + encode zstd gzip + reverse_proxy melodymuse:3000 +} diff --git a/package.json b/package.json index fad4cb5..642146c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "melodymuse", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", - "description": "Generate all assets needed for a Suno AI song from a free-text input. Runs entirely in the browser — no backend.", + "description": "Generate all assets needed for a Suno AI song from a free-text input. The LLM API key lives on the server.", "scripts": { "dev": "vite", + "dev:server": "node server.mjs", "build": "tsc -b && vite build", + "start": "node server.mjs", "preview": "vite preview", "lint": "tsc -b --noEmit" }, diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..351e217 --- /dev/null +++ b/server.mjs @@ -0,0 +1,444 @@ +// MelodyMuse — single-file production server. +// +// Responsibilities: +// 1. Serve the built Vite SPA from `./dist/` (SPA fallback to index.html). +// 2. Proxy LLM calls through three JSON endpoints so the browser never +// sees the API key. +// +// Run with: node server.mjs +// Or via: npm start +// +// Required env vars (the server exits if any are missing): +// LLM_ENDPOINT — OpenAI-compatible base URL, e.g. https://api.minimax.chat/v1 +// LLM_API_KEY — provider secret key +// Optional env vars: +// LLM_MODEL — model name (default: MiniMax-M3) +// PORT — listen port (default: 3000) +// CORS_ORIGIN — "*" to allow any origin, or a specific origin +// (default: "*", fine for single-user personal use) + +import { createServer } from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + buildStylePrompt, + buildSystemPrompt, + buildUserMessage, +} from './server/prompts.mjs'; + +// ────────────────────────────────────────────────────────────────────────────── +// Configuration +// ────────────────────────────────────────────────────────────────────────────── + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const DIST = join(__dirname, 'dist'); +const PORT = parseInt(process.env.PORT || '3000', 10); +const CORS_ORIGIN = process.env.CORS_ORIGIN || '*'; + +const LLM = { + endpoint: (process.env.LLM_ENDPOINT || '').replace(/\/+$/, ''), + apiKey: process.env.LLM_API_KEY || '', + model: process.env.LLM_MODEL || 'MiniMax-M3', +}; + +if (!LLM.endpoint || !LLM.apiKey) { + console.error(''); + console.error(' [FATAL] LLM_ENDPOINT and LLM_API_KEY must be set.'); + console.error(` LLM_ENDPOINT: ${LLM.endpoint || ''}`); + console.error(` LLM_API_KEY: ${LLM.apiKey ? '********' : ''}`); + console.error(''); + process.exit(1); +} + +console.log(`[startup] LLM endpoint: ${LLM.endpoint}`); +console.log(`[startup] LLM model: ${LLM.model}`); +console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`); + +// ────────────────────────────────────────────────────────────────────────────── +// JSON extraction + shape validation +// ────────────────────────────────────────────────────────────────────────────── + +function isObject(v) { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +// Pull a JSON object out of a model reply that may have wrapped it in +// ```json ... ``` fences or added a brief preamble. +function extractJson(text) { + const trimmed = text.trim(); + try { return JSON.parse(trimmed); } catch { /* fall through */ } + + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced) { + try { return JSON.parse(fenced[1].trim()); } catch { /* fall through */ } + } + + const firstBrace = trimmed.indexOf('{'); + const lastBrace = trimmed.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace > firstBrace) { + const slice = trimmed.slice(firstBrace, lastBrace + 1); + try { return JSON.parse(slice); } catch { /* fall through */ } + } + + throw new Error('Model response did not contain a parseable JSON object'); +} + +const REQUIRED_KEYS = [ + 'titles', + 'lyrics', + 'style', + 'negative_style', + 'youtube_description', + 'video_prompts', +]; + +function validateShape(parsed) { + if (!isObject(parsed)) throw new Error('Model response is not an object'); + for (const k of REQUIRED_KEYS) { + if (!(k in parsed)) continue; // partial regen may omit keys + const v = parsed[k]; + if (k === 'titles') { + if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) { + throw new Error('"titles" must be an array of strings'); + } + } else if (k === 'video_prompts') { + if (!Array.isArray(v)) throw new Error('"video_prompts" must be an array'); + } else if (typeof v !== 'string') { + throw new Error(`"${k}" must be a string`); + } + } +} + +function validateFullAssets(parsed) { + if (!isObject(parsed)) throw new Error('Model response is not an object'); + for (const k of REQUIRED_KEYS) { + if (!(k in parsed)) { + throw new Error(`Model response is missing required key "${k}"`); + } + } + validateShape(parsed); +} + +// For partial regen, drop any keys the model returned that we didn't ask for. +// Keeps the merged state predictable. +function pickResultForSection(section, result) { + switch (section) { + case 'titles': + return { titles: result.titles }; + case 'lyrics': + return { lyrics: result.lyrics }; + case 'style': + return { + style: result.style, + negative_style: result.negative_style, + }; + case 'video_prompts': + return { video_prompts: result.video_prompts }; + case 'youtube_description': + return { youtube_description: result.youtube_description }; + default: + return result; + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Provider call +// ────────────────────────────────────────────────────────────────────────────── + +async function callProvider(messages, opts = {}, signal) { + const url = `${LLM.endpoint}/chat/completions`; + let resp; + try { + resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${LLM.apiKey}`, + }, + body: JSON.stringify({ + model: LLM.model, + max_tokens: opts.max_tokens ?? 4000, + messages, + }), + signal, + }); + } catch (err) { + if (err && err.name === 'AbortError') throw err; + throw new Error( + `Could not reach ${url}. Check LLM_ENDPOINT and that the provider is reachable from this server. (Details: ${err.message})`, + ); + } + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error( + `Provider returned ${resp.status} ${resp.statusText}${text ? `: ${text.slice(0, 400)}` : ''}`, + ); + } + + const data = await resp.json(); + const content = data.choices?.[0]?.message?.content; + if (typeof content !== 'string' || !content.trim()) { + throw new Error('Provider response did not include a message'); + } + return content; +} + +// Strip a leading "Style: " or surrounding quotes that some models like to add. +function cleanStyleString(s) { + let out = s.trim(); + // Strip a single pair of wrapping quotes, either kind. + if ( + (out.startsWith('"') && out.endsWith('"')) || + (out.startsWith("'") && out.endsWith("'")) + ) { + out = out.slice(1, -1).trim(); + } + // Strip a "Style:" / "Style prompt:" prefix if the model added one. + out = out.replace(/^(style\s*(prompt)?\s*[:\-]\s*)/i, ''); + // Strip a trailing period. + out = out.replace(/\.+$/, '').trim(); + return out; +} + +// ────────────────────────────────────────────────────────────────────────────── +// HTTP helpers +// ────────────────────────────────────────────────────────────────────────────── + +const STATIC_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.ico': 'image/x-icon', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2':'font/woff2', + '.ttf': 'font/ttf', + '.map': 'application/json; charset=utf-8', +}; + +function json(res, body, status = 200) { + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk) => { + data += chunk; + // Guard against runaway payloads. + if (data.length > 1_000_000) { + reject(new Error('Request body too large')); + req.destroy(); + } + }); + req.on('end', () => { + if (!data) return resolve({}); + try { resolve(JSON.parse(data)); } + catch { reject(new Error('Invalid JSON body')); } + }); + req.on('error', reject); + }); +} + +// Combined abort signal: the request stream closing (client disconnect) OR +// the explicit AbortSignal passed in. +function combinedSignal(req, explicit) { + const ctrl = new AbortController(); + const onClose = () => ctrl.abort(new Error('client disconnected')); + req.once('close', onClose); + if (explicit) { + if (explicit.aborted) ctrl.abort(explicit.reason); + else explicit.addEventListener('abort', () => ctrl.abort(explicit.reason), { once: true }); + } + return ctrl.signal; +} + +async function serveStatic(req, res) { + // Strip query string and decode. + const urlPath = decodeURIComponent(req.url.split('?')[0]); + // Disallow path traversal. + if (urlPath.includes('..')) { + res.writeHead(400); res.end('Bad request'); return; + } + + let filePath = join(DIST, urlPath); + try { + const s = await stat(filePath); + if (s.isDirectory()) filePath = join(filePath, 'index.html'); + } catch { + // SPA fallback — serve index.html for unknown routes. + filePath = join(DIST, 'index.html'); + } + + try { + const data = await readFile(filePath); + const ext = extname(filePath).toLowerCase(); + const type = STATIC_TYPES[ext] || 'application/octet-stream'; + // Hashed Vite assets get long-lived caching; index.html and the un-hashed + // root never get cached so deploys take effect immediately. + const isHashedAsset = /\/assets\//.test(urlPath) && /\.[a-z0-9]{8}\./.test(urlPath); + const cacheControl = isHashedAsset + ? 'public, max-age=31536000, immutable' + : 'no-cache'; + res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cacheControl }); + res.end(data); + } catch (err) { + res.writeHead(500); res.end('Internal error'); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Route handlers +// ────────────────────────────────────────────────────────────────────────────── + +function applyCors(res) { + res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); +} + +async function handleHealth(req, res) { + json(res, { + ok: true, + llm_configured: true, + model: LLM.model, + endpoint: LLM.endpoint, + }); +} + +async function handleStyleRandom(req, res) { + let body; + try { body = await readJsonBody(req); } + catch (err) { return json(res, { error: err.message }, 400); } + + const mode = body?.mode === 'crazy' ? 'crazy' : 'normal'; + const signal = combinedSignal(req); + + try { + const content = await callProvider( + [ + { role: 'system', content: buildStylePrompt(mode) }, + { role: 'user', content: 'Generate one style description now.' }, + ], + { max_tokens: 120 }, + signal, + ); + const style = cleanStyleString(content); + if (!style) throw new Error('Model returned an empty style'); + json(res, { style, mode }); + } catch (err) { + if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) { + return; // Client gave up; no point responding. + } + console.error('[style/random]', err); + json(res, { error: err.message || 'Failed to generate style' }, 500); + } +} + +async function handleGenerate(req, res) { + let body; + try { body = await readJsonBody(req); } + catch (err) { return json(res, { error: err.message }, 400); } + + if (!body || typeof body.input !== 'string' || !body.input.trim()) { + return json(res, { error: 'Missing "input"' }, 400); + } + if (!body.section) body.section = 'all'; + if (!body.vocals) body.vocals = 'vocals'; + if (!body.language) body.language = 'English'; + + const signal = combinedSignal(req); + + try { + const content = await callProvider( + [ + { role: 'system', content: buildSystemPrompt(body) }, + { role: 'user', content: buildUserMessage(body) }, + ], + { max_tokens: 4000 }, + signal, + ); + + const parsed = extractJson(content); + + if (body.section === 'all') { + validateFullAssets(parsed); + } else { + validateShape(parsed); + } + + const result = body.section === 'all' ? parsed : pickResultForSection(body.section, parsed); + json(res, result); + } catch (err) { + if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) { + return; + } + console.error('[generate]', err); + json(res, { error: err.message || 'Generation failed' }, 500); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Server +// ────────────────────────────────────────────────────────────────────────────── + +const server = createServer(async (req, res) => { + applyCors(res); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + // API + if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') { + return handleHealth(req, res); + } + if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') { + return handleGenerate(req, res); + } + if (req.method === 'POST' && req.url.split('?')[0] === '/api/style/random') { + return handleStyleRandom(req, res); + } + + // Anything under /api that didn't match is a 404 (don't fall through to the SPA). + if (req.url.startsWith('/api/')) { + return json(res, { error: 'Not found' }, 404); + } + + if (req.method === 'GET') { + return serveStatic(req, res); + } + + res.writeHead(405); + res.end('Method not allowed'); +}); + +server.listen(PORT, '0.0.0.0', () => { + // The boot log already happened above. +}); + +// Graceful shutdown so the process exits cleanly when Docker stops it. +for (const sig of ['SIGINT', 'SIGTERM']) { + process.on(sig, () => { + console.log(`[shutdown] ${sig} received, closing server…`); + server.close(() => process.exit(0)); + // Hard exit if it takes too long. + setTimeout(() => process.exit(1), 5000).unref(); + }); +} diff --git a/src/lib/prompts.ts b/server/prompts.mjs similarity index 70% rename from src/lib/prompts.ts rename to server/prompts.mjs index ec6e33e..b3942aa 100644 --- a/src/lib/prompts.ts +++ b/server/prompts.mjs @@ -1,7 +1,5 @@ -// System + user prompt construction for the LLM. Keeping it in its own module -// avoids a 200-line string literal in the llm module entry point. - -import type { GenerateRequest } from './types'; +// System + user prompt construction for the LLM. Server-side only — the +// browser never sees these prompts. export const SYSTEM_PROMPT_ALL = `You are a music production assistant specializing in Suno AI song creation. @@ -14,6 +12,12 @@ LANGUAGE RULES (strict): - "style", "negative_style", and all "video_prompts" → ALWAYS English, regardless of the language field +STYLE HINT (optional): +- If the user provided a "style_hint" (a short style description in the user + message), use it as the basis for the "style" field. Expand and refine it + into the full 120-word comma-separated Suno style description required below. +- If no style_hint is provided, invent a coherent style from scratch. + REQUIRED JSON STRUCTURE: { "titles": [ @@ -48,7 +52,7 @@ Keep each video_prompts[].prompt under 900 characters (excluding the Negative li Recommended video tools (choose the best fit per prompt): Runway Gen-3 Alpha, Kling AI, Luma Dream Machine, Pika 2.0, Haiper. Never recommend Sora.`; -const PARTIAL_SYSTEM_PROMPT = `You are a music production assistant specializing in Suno AI song creation. +const SYSTEM_PROMPT_PARTIAL = `You are a music production assistant specializing in Suno AI song creation. The user has an existing song and wants to regenerate ONE section. Output ONLY valid JSON — no markdown, no backticks, no preamble. @@ -80,16 +84,55 @@ QUALITY CONSTRAINTS: - youtube_description: follow the hook → description → divider → lyrics → divider → CTA → hashtags structure.`; -export function buildSystemPrompt(req: GenerateRequest): string { +export const SYSTEM_PROMPT_STYLE_NORMAL = `You are a Suno style-prompt generator. + +Output ONE short Suno style description. + +Requirements: +- A cohesive, production-ready combination of genres, instruments, BPM, mood, + and vocal style (or "instrumental, no vocals" if appropriate). +- Comma-separated keywords. No sentences, no bullet points, no labels. +- Maximum 25 words. +- Output ONLY the style description line — no quotes, no commentary, no prefix, + no trailing punctuation. + +Examples of the expected output (do NOT copy these verbatim): +- dark synthwave, analog pads, driving bassline, 110 BPM, male vocals +- indie folk, fingerpicked acoustic guitar, soft female vocals, 90 BPM +- trap, heavy 808s, dark piano, fast hi-hats, 140 BPM, autotune vocals +- dreamy shoegaze, layered reverb guitars, hushed vocals, 95 BPM`; + +export const SYSTEM_PROMPT_STYLE_CRAZY = `You are a Suno style-prompt generator with permission to break conventions. + +Output ONE short Suno style description. + +Requirements: +- DELIBERATELY combine genres, eras, or instruments that don't normally mix. +- The combination should still be parseable by Suno: use real instrument and + genre names, include a BPM, mention a vocal style. +- Comma-separated keywords. No sentences, no bullet points, no labels. +- Maximum 25 words. +- Output ONLY the style description line — no quotes, no commentary, no prefix, + no trailing punctuation. + +Examples of the expected output (do NOT copy these verbatim): +- gregorian chant trap, deep male choir, sub 808s, 140 BPM, dark reverb +- baroque chamber orchestra meets dubstep, cellos, glitch drops, 160 BPM +- lo-fi mariachi breakcore, trumpets, chopped breaks, 90 BPM, vinyl crackle +- medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM +- tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM`; + +export function buildSystemPrompt(req) { if (req.section === 'all') return SYSTEM_PROMPT_ALL; - return PARTIAL_SYSTEM_PROMPT; + return SYSTEM_PROMPT_PARTIAL; } -export function buildUserMessage(req: GenerateRequest): string { - const lines: string[] = []; +export function buildUserMessage(req) { + const lines = []; lines.push(`Music idea: ${req.input}`); lines.push(`Language: ${req.language}`); if (req.mood) lines.push(`Mood: ${req.mood}`); + if (req.style_hint) lines.push(`Style hint (user-provided): ${req.style_hint}`); lines.push(`Vocals: ${req.vocals}`); lines.push(`Section to generate: ${req.section}`); @@ -101,3 +144,7 @@ export function buildUserMessage(req: GenerateRequest): string { return lines.join('\n'); } + +export function buildStylePrompt(mode) { + return mode === 'crazy' ? SYSTEM_PROMPT_STYLE_CRAZY : SYSTEM_PROMPT_STYLE_NORMAL; +} diff --git a/src/components/ConfigBanner.tsx b/src/components/ConfigBanner.tsx deleted file mode 100644 index c56b530..0000000 --- a/src/components/ConfigBanner.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { X } from 'lucide-react'; - -interface ConfigBannerProps { - message: string; - linkTo: string; - linkLabel: string; -} - -export function ConfigBanner({ message, linkTo, linkLabel }: ConfigBannerProps) { - const [dismissed, setDismissed] = useState(false); - if (dismissed) return null; - return ( -
-

- {message}{' '} - - {linkLabel} - -

- -
- ); -} diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx index ea7d316..0555469 100644 --- a/src/components/InputPanel.tsx +++ b/src/components/InputPanel.tsx @@ -7,10 +7,14 @@ import { Settings as SettingsIcon, Sparkles, Shuffle, + Flame, + Wand2, } from "lucide-react"; import { EqualizerIcon } from "./EqualizerIcon"; import type { InputValues, Language, Vocals } from "../lib/types"; import { formatElapsed, useElapsed } from "../lib/useElapsed"; +import { randomStyle } from "../lib/llm"; +import { useToast } from "../lib/toast"; export type { InputValues }; @@ -68,7 +72,11 @@ export function InputPanel({ cancelButton, }: InputPanelProps) { const [optionsOpen, setOptionsOpen] = useState(false); + const [styleLoading, setStyleLoading] = useState( + null, + ); const elapsed = useElapsed(loading); + const toast = useToast(); const set = (key: K, value: InputValues[K]) => onChange({ ...values, [key]: value }); @@ -98,6 +106,25 @@ export function InputPanel({ }); }; + const handleStyleRandom = async (mode: "normal" | "crazy") => { + if (styleLoading) return; // already running, don't fire two in parallel + const ctrl = new AbortController(); + setStyleLoading(mode); + try { + const style = await randomStyle(mode, ctrl.signal); + onChange({ ...values, style_hint: style }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + toast.error( + err instanceof Error + ? err.message + : `Could not generate a ${mode} style — please try again`, + ); + } finally { + setStyleLoading((curr) => (curr === mode ? null : curr)); + } + }; + return (
{/* Animated gradient backdrop behind the header */} @@ -144,10 +171,7 @@ export function InputPanel({ />

- {navigator?.platform?.toLowerCase().includes("mac") - ? "⌘" - : "Ctrl"} - +Enter + {isMac() ? "⌘" : "Ctrl"}+Enter {" "} to generate

@@ -199,12 +223,20 @@ export function InputPanel({ )}
+ set("style_hint", v)} + onRandom={handleStyleRandom} + /> +
); } + +interface StyleFieldProps { + value: string; + loading: null | "normal" | "crazy"; + onChange: (v: string) => void; + onRandom: (mode: "normal" | "crazy") => void; +} + +function StyleField({ value, loading, onChange, onRandom }: StyleFieldProps) { + return ( +
+ +