# Project export: ProduceThing

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: Most AI music tools generate songs. ProduceThing helps you write better ones. It coaches lyrics in real time, then an autonomous agent arranges and refines them into a finished track.
- Devpost: https://devpost.com/software/producething
- GitHub: https://github.com/ads2280/treehacks26
- Video: https://www.youtube.com/embed/m8T-JngBSGo?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Anikasomaia (26 commits), jkdreamr (17 commits), Claude Opus 4.6 (9 commits)

## Devpost submission (written by the team)

### Inspiration

We've all got Notes apps full of half-written lyrics and 2 a.m. voice memos that we never actually finish. The ideas are there – we can hear the hooks and the beats clearly in our heads – but there's usually a big gap between having a spark of an idea and actually sitting down to produce it. At the same time, we love the process of writing music. We didn't want a "generate" button that does everything for us; we wanted to stay in the driver's seat – choosing the words, tweaking the melodies, and building the track piece by piece. We built ProduceThing to help bridge that gap. It's a studio that helps you finish what you start, acting as a collaborator that handles the technical friction while you keep the creative control.

### What it does

ProduceThing is an AI music studio built around writing. Grammarly for lyrics A real-time lyric editor that highlights clichés, filler words, repetition, and weak phrasing as you type – with one-click tighten, de-cliché, punch-up, and hook suggestions. Cursor for songwriting Ghost-text autocomplete that understands your rhyme scheme, cadence, and full-song context. Press Tab to accept. AI co-writer + agent Chat with GPT/Claude to iterate on lines, fix flow, or generate ideas. In autonomous mode, Claude can compose, layer, and refine an entire session from a single prompt. Layer-based production Tracks are split into stems so you can add, remove, regenerate, mute/solo, A/B compare, and export — like a lightweight DAW. Music video generation Generate a lip-synced video from a selfie with AI backgrounds and share-ready output.

### How we built it

The Frontend Built with Next.js 16, React 19, and TypeScript. We used a transparent textarea layered over a highlighted overlay – the same pattern used in modern code editors – to provide real-time lyric analysis without breaking the writing flow. The Audio Engine We integrated the Suno API for music generation and 12-stem separation. To speed things up, we deployed htdemucs on Modal using T4 GPUs for parallel 3-stem separation. We ran both pipelines in a race – whichever finished first (usually Modal in ~20s vs ~60s) served the user. AI Orchestration Powered by Vercel AI SDK v6 with dual-model routing: GPT-5 Nano handles fast chat and lyric rewrites. Claude Opus 4.6 manages the autonomous "Agent Mode." Lyric Analysis Engine A custom client-side engine that tracks 40+ cliché phrases, 15 filler words, and uses suffix-normalized rhyme clustering and syllable counting. UI/UX Styled with Tailwind CSS 4 and Radix UI, with multi-track mixing handled via the Web Audio API and waveform-playlist.

### Challenges we ran into

Defining the "unexplored" space: AI tools already offer massive flexibility in generating sounds, but we realized the lyric space was still largely a "black box" of generic text. We spent hours riffing with mentors and sponsors, trying to figure out where we could actually contribute. Designing the agent: Designing the agent wasn't just engineering – it was musicology. We had to teach it the nuances of bridge transitions, tension and release, and song architecture so it could act as a peer, not a script. Balancing vibes and rigid songwriting constraints: Lyrics are a messy mix of math and soul. We had to build an editor that tracks rigid constraints – like syllable counts and rhyme density – without making the writer feel like they were filling out a spreadsheet. The challenge was keeping the creative flow alive while the engine calculated the structure in the background.

### Accomplishments we're proud of

We successfully built a parallelized audio pipeline that races Modal (T4 GPUs) against Suno's native processing. Dropping the stem separation time from ~60s to ~20s wasn't just a performance win; it saved the "creative flow" of the entire app. The Lyric Coach actually coaches: Our client-side analysis engine doesn't just find rhymes; it genuinely improves writing. Seeing it catch clichés and filler words in real-time makes the AI feel like a rigorous editor rather than just a ghostwriter. Getting Claude Opus 4.6 to autonomously plan and execute a multi-track session – orchestrating lyrics, structure, and stems – was our biggest technical breakthrough. Watching it "think" through a song structure is a true "wow" moment.

### What we learned

Agency > Automation: Most people don't want an AI to make a song for them; they want the AI to help them make a song better. Control is the most important feature we built. Hybrid Logic wins: LLMs are great for brainstorming, but they're bad at counting. Combining hard-coded rule-based analysis (syllable counting) with LLM rewrites gave us much better results than an LLM alone. When building for artists, you have to engineer for flow. In music, a 60-second wait for a stem separation kills the creative flow. We learned that engineering for speed – like our parallelized Modal + Suno pipeline – isn't just a technical "nice-to-have"; it's a requirement for staying in the "flow state."

### What's next

Live vocal coaching. We want to move beyond lyrics and stems by adding real-time recording capabilities. Using the same logic as our lyric analyzer, we'll build a vocal coach that provides instant feedback on pitch, rhythm, and delivery as you record. Translate to different languages This is a fun challenge because translating a song isn't just swapping words – it's about preserving the rhyme and the rhythm. We want to build a tool that helps artists "port" their tracks into new languages without losing the syllable count or the "vibe."

## README (from the GitHub repository)

<h1 align="center">Producer</h1>
<p align="center"><strong>Most AI music tools generate songs. Producer helps you write better ones. It coaches lyrics in real time, then an autonomous agent arranges and refines them into a finished track.</strong></p>
<p align="center"><em>TreeHacks 2026</em></p>

<p align="center"><img src="docs/demo.gif" width="720" /></p>

---

## Technical Deep Dive

### 1. Parallelized Stem Separation Pipeline

The biggest UX bottleneck in stem separation is latency. Suno's `/stem` endpoint splits a track into 12 stems but takes 60-120s. We couldn't ship that wait time.

**Solution: Race two pipelines in parallel and deduplicate with a first-wins strategy.**

```
Track complete ──┬── Modal Demucs (T4 GPU, htdemucs) ── 3 stems in ~20s
                 │
                 └── Suno /stem API ───────────────── 12 stems in 60-120s
                                                        │
                              deliveredStems (Set) ◄────┘
                              First pipeline to deliver each stem wins.
```

**How it works:**

- After Suno generation completes (`status=complete`), we fire **both** pipelines simultaneously
- A `Set<StemType>` called `deliveredStems` tracks what's arrived — if Demucs delivers `drums` first, Suno's `drums` is silently dropped
- **Core stems** (drums, vocals, bass) buffer in a `pendingCore` Map. Once all 3 arrive — from whichever pipeline — we do an **atomic swap**: remove the full-mix placeholder layer, add 3 individual stem layers in one React state update. No flicker.
- **Non-core stems** (guitar, keyboard, strings, etc.) go to `stemCache` for instant manual adds later
- Demucs failure is swallowed with `.catch()` — Suno handles all 12 stems as fallback. **Zero degradation.**

**The Modal side:** Our `demucs_endpoint.py` runs `htdemucs` (Hybrid Transformer Demucs) on a T4 GPU with pre-downloaded weights baked into the container image — no runtime download delay. One warm instance stays alive (`keep_warm=1`) to eliminate cold starts.

**Result:** Users get their first playable stems in ~20s instead of 60-120s. The remaining 9 stems trickle in via Suno's progressive polling while the user is already mixing.

### 2. Lyric Analysis Engine

We built a real-time lyric analysis system that runs **entirely client-side** — no round-trip to an LLM for basic feedback. It catches problems as you type.

**Syllable counting** uses the `syllable` npm package for per-line cadence analysis. We feed this into a **cadence profiler** (`rap-theory.ts`) that computes avg/min/max syllables, variance, and classifies flow as "tight," "balanced," or "loose."

**Rhyme detection** uses a custom phonetic normalization pipeline:

```typescript
// English spelling is a disaster. We normalize before comparing.
const SUFFIX_NORMALIZATIONS: [RegExp, string][] = [
  [/ight$/, "ite"],   // "night" → "nite"
  [/ould$/, "ood"],   // "would" → "wood"
  [/tion$/, "shun"],  // "nation" → "nashun"
  [/sion$/, "shun"],  // "vision" → "vishun"
  [/ck$/, "k"],       // "rock" → "rok"
  // ... 6 more rules
];
```

After normalization, we extract the last 3 characters as a `rhymeKey` and cluster lines that share endings. Lines with ≥2 matches form rhyme groups — this feeds into an **ABAB rhyme scheme builder** that labels patterns across verses.

**Cliche detection** matches against 48 hardcoded phrases ("heart on my sleeve," "dance in the rain," etc.) and highlights them inline. **Filler word detection** flags weak words ("basically," "literally," "you know"). **Repetition tracking** tokenizes the full lyric, counts unigrams appearing ≥3x and n-grams (2-3 word phrases) appearing ≥2x, and annotates them with `"word" appears Nx` labels.

**On top of the client-side engine**, we have an LLM-powered insight layer (`/api/lyrics/analyze`) that uses GPT-4o-mini to generate thematic analysis, rhyme targets (5 rhymes per target word), and writing tips — with a graceful fallback so the panel never breaks if the LLM fails.

### 3. Dual-Model AI Routing + Agent Mode

We don't use one model for everything. Different tasks need different tradeoffs:

| Mode | Model | Why |
|------|-------|-----|
| **Normal** | GPT-5 Nano | Fast, cheap. Good for "add more bass" or "make it darker." Sub-second responses. |
| **Agent** | Claude Opus 4.6 | Powerful multi-step reasoning. Can compose an entire track autonomously from "make me a lo-fi hip hop beat." |

**The routing logic** is dead simple — if `agentMode` is true, we always use Claude. Otherwise, the user toggles between OpenAI and Anthropic.

**Agent mode is where it gets interesting.** Claude Opus operates in a **Plan → Execute → Observe → Reflect** loop:

1. **Plan**: The agent reasons about what a genre needs ("lo-fi hip hop typically has mellow drums, a jazzy bass, and vinyl-crackle FX")
2. **Execute**: Calls `generate_track` → waits for stem separation → chains `add_layer` calls for cached stems
3. **Observe**: Parses enriched tool results that include cached stem lists and layer counts
4. **Reflect**: Summarizes what was built and suggests next refinements

The loop is powered by Vercel AI SDK v6's `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` — when the assistant's response includes tool calls, the SDK automatically sends tool results back, creating a natural agent loop without custom orchestration.

**Critical implementation detail:** In agent mode, tool callbacks `await` the full Suno generation + stem separation pipeline and return detailed state. In normal mode, they fire-and-forget and return immediately. Same tools, different depth — because the agent needs rich context to chain decisions, but a human user just needs the UI to update.

**6 tools available**: `generate_track`, `add_layer`, `regenerate_layer`, `remove_layer`, `set_lyrics`, `get_composition_state`

---

## System Architecture

```
┌─────────────────────────────────────────────────────────────────────┐
│                        User (Browser)                               │
│                                                                     │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐  ┌───────────────────┐  │
│  │ Chat     │  │ Layer    │  │ Waveform  │  │ Transport +       │  │
│  │ Panel    │  │ Sidebar  │  │ Display   │  │ Master Volume     │  │
│  │          │  │          │  │           │  │                   │  │
│  │ Drag a   │  │ Volume   │  │ waveform- │  │ Web Audio API     │  │
│  │ layer ──►│  │ Mute     │  │ playlist  │  │ (GainNode mixing) │  │
│  │ to chat  │  │ Solo     │  │ (canvas)  │  │                   │  │
│  └────┬─────┘  └──────────┘  └───────────┘  └───────────────────┘  │
│       │                                                             │
└───────┼─────────────────────────────────────────────────────────────┘
        │
        ▼
┌───────────────────────── Next.js API Routes ─────────────────────────┐
│                                                                      │
│  /api/chat ──────────┬──► GPT-5 Nano (normal mode)                   │
│                      └──► Claude Opus 4.6 (agent mode)               │
│                           │                                          │
│                           ▼ (tool calls)                             │
│  /api/generate ─────────► Suno /generate ──► poll /clips             │
│                                                   │                  │
│                                    status=complete │                  │
│                                                   ▼                  │
│  /api/stem ─────────────► Suno /stem (12 stems) ──┐                  │
│  /api/stem-demucs ──────► Modal htdemucs (3 stems)─┤ RACE            │
│                                                    ▼                 │
│                                          deliveredStems (Set)        │
│                                          First-wins dedup            │
│                                                   │                  

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 137 recognized source files, 626 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Vercel AI SDK (technology) — detected in the code
- Vercel (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers

## Codebase structure (from repository index)

### Files (120 of 148)

```
.claude/ralph-loop.49278.local.md
.gitignore
app/.env.example
app/.gitignore
app/.npmrc
app/app/api/audio-proxy/route.ts
app/app/api/chat/route.ts
app/app/api/clips/route.ts
app/app/api/generate-backgrounds/route.ts
app/app/api/generate/route.ts
app/app/api/heygen/generate/route.ts
app/app/api/heygen/status/route.ts
app/app/api/heygen/streaming-token/route.ts
app/app/api/heygen/upload/route.ts
app/app/api/lyrics/analyze/route.test.ts
app/app/api/lyrics/analyze/route.ts
app/app/api/lyrics/autocomplete/route.ts
app/app/api/lyrics/chat/route.ts
app/app/api/lyrics/pro-assist/route.ts
app/app/api/lyrics/produce/route.test.ts
app/app/api/lyrics/produce/route.ts
app/app/api/stem-demucs/route.ts
app/app/api/stem/route.ts
app/app/api/video/upload/route.ts
app/app/globals.css
app/app/layout.tsx
app/app/page.tsx
app/app/studio/page.tsx
app/app/studio/video/page.tsx
app/components.json
app/components/icons/dj-head.tsx
app/components/studio/chat-panel.tsx
app/components/studio/create-panel.tsx
app/components/studio/generation-overlay.tsx
app/components/studio/layer-sidebar.tsx
app/components/studio/lyrics-panel.tsx
app/components/studio/lyrics/analysis-summary.tsx
app/components/studio/lyrics/highlighted-line.tsx
app/components/studio/lyrics/suggestion-card.tsx
app/components/studio/modals.tsx
app/components/studio/studio-header.tsx
app/components/studio/studio-landing.tsx
app/components/studio/toast-provider.tsx
app/components/studio/transport-bar.tsx
app/components/studio/waveform-display.tsx
app/components/ui/alert.tsx
app/components/ui/badge.tsx
app/components/ui/breadcrumb.tsx
app/components/ui/button-group.tsx
app/components/ui/button.tsx
app/components/ui/card.tsx
app/components/ui/dialog.tsx
app/components/ui/empty.tsx
app/components/ui/field.tsx
app/components/ui/input-group.tsx
app/components/ui/input.tsx
app/components/ui/item.tsx
app/components/ui/kbd.tsx
app/components/ui/pagination.tsx
app/components/ui/sheet.tsx
app/components/ui/skeleton.tsx
app/components/ui/slider.tsx
app/components/ui/spinner.tsx
app/components/ui/table.tsx
app/components/ui/textarea.tsx
app/components/ui/toast.tsx
app/components/ui/toaster.tsx
app/components/ui/use-mobile.tsx
app/components/ui/use-toast.ts
app/components/video/camera-capture.tsx
app/components/video/streaming-preview.tsx
app/components/video/style-selector.tsx
app/components/video/video-generation-overlay.tsx
app/components/video/video-result.tsx
app/docs/specs/music-video-generation.spec.md
app/hooks/__tests__/use-project.test.ts
app/hooks/use-lyrics-analysis.ts
app/hooks/use-mobile.ts
app/hooks/use-project.ts
app/hooks/use-toast.ts
app/hooks/use-waveform-playlist.ts
app/lib/__tests__/audio-utils.test.ts
app/lib/__tests__/lyrics-analysis.test.ts
app/lib/__tests__/lyrics-validate.test.ts
app/lib/__tests__/suno.test.ts
app/lib/api.ts
app/lib/audio-utils.ts
app/lib/demucs.ts
app/lib/heygen.ts
app/lib/layertune-types.ts
app/lib/lyrics-analysis.ts
app/lib/lyrics-assistant.ts
app/lib/lyrics-filler.ts
app/lib/lyrics-hooks.ts
app/lib/lyrics-parser.ts
app/lib/lyrics-prompts.ts
app/lib/lyrics-types.ts
app/lib/lyrics-utils.ts
app/lib/lyrics-validate.ts
app/lib/rap-theory.ts
app/lib/suno.ts
app/lib/utils.ts
app/next-env.d.ts
app/next.config.mjs
app/next.config.ts
app/package.json
app/postcss.config.mjs
app/public/images/.gitkeep
app/README.md
app/src/app/api/audio-proxy/route.ts
app/src/app/api/clips/route.ts
app/src/app/api/generate/route.ts
app/src/app/api/stem/route.ts
app/src/app/globals.css
app/src/app/layout.tsx
app/src/app/page.tsx
app/src/components/ABComparison.tsx
app/src/components/ConfirmDialog.tsx
app/src/components/CreatePanel.tsx
app/src/components/ExportPanel.tsx
[28 more files omitted for size]
```

### Dependencies

- app/package.json: @ai-sdk/anthropic@^3.0.44, @ai-sdk/openai@^3.0.29, @ai-sdk/react@^3.0.88, @heygen/streaming-avatar@^2.1.0, @hookform/resolvers@^3.10.0, @radix-ui/react-accordion@1.2.2, @radix-ui/react-alert-dialog@1.1.4, @radix-ui/react-aspect-ratio@1.1.1, @radix-ui/react-avatar@1.1.2, @radix-ui/react-checkbox@1.1.3, @radix-ui/react-collapsible@1.1.2, @radix-ui/react-context-menu@2.2.4, @radix-ui/react-dialog@1.1.4, @radix-ui/react-slider@1.2.2, @radix-ui/react-slot@1.1.1, @radix-ui/react-toast@1.2.4, @tailwindcss/postcss@^4.1.9, @testing-library/dom@^10.4.1, @testing-library/jest-dom@^6.9.1, @testing-library/react@^16.3.2, @types/node@^22, @types/react@^19, @types/react-dom@^19, @vercel/analytics@1.3.1, @vercel/blob@^2.2.0, @vitejs/plugin-react@^5.1.4, ai@^6.0.86, autoprefixer@^10.4.20, class-variance-authority@^0.7.1, clsx@^2.1.1, cmdk@1.0.4, date-fns@4.1.0, embla-carousel-react@8.5.1, event-emitter@^0.3.5, input-otp@1.4.1, jsdom@^28.0.0, livekit-client@^2.17.1, lucide-react@^0.454.0, next@16.1.6, next-themes@^0.4.6, openai@^6.22.0, postcss@^8.5, react@19.2.4, react-day-picker@9.8.0, react-dom@19.2.4, react-hook-form@^7.60.0, react-markdown@^10.1.0, syllable@^5.0.1, tailwind-merge@^3.3.1, tailwindcss@^4.1.9, tailwindcss-animate@^1.0.7, tw-animate-css@1.3.3, typescript@^5, vaul@^1.1.2, vitest@^4.0.18, waveform-playlist@^4.3.3, zod@3.25.76

### Recent commits (newest first)

- Description of Producer app
- Nit in README
- Revise README to enhance project description
- Revise README to highlight ProduceThing's features
- Merge pull request #9 from ads2280/anika/lyrics
- Replace demo GIF with final version
- Rewrite README for hackathon judging with demo GIF and technical deep dive
- Backend endpoints for lyric analysis, chat panel fix
- fix chat panel
- Merge origin/main into anika/lyrics and resolve lyrics file conflicts
- backend endpoints for lyric analysis
- Merge pull request #7 from ads2280/readme-update
- Update README with holistic project overview
- Add .claude, node_modules, .next, and .DS_Store to .gitignore
- Merge pull request #6 from ads2280/jkdreamr/heygen-music-video
- Implement HeyGen music video generation pipeline with AI backgrounds
- Merge pull request #5 from ads2280/feat/lyrics-grammarly-assistant
- merge: sync feature branch with latest origin/main
- feat: pro rap lyric assistant with LLM autocomplete, coach, and pro pass
- Merge pull request #4 from ads2280/jkdreamr/add-env-config

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
# ProduceThing — AI Music Composition Studio (TreeHacks 2026)

## Project Vision
Layer-by-layer music composition through natural language. Users describe vibes, add layers (drums, melody, vocals, bass), regenerate individual layers, and export. "I made this myself" feeling.

## Core Principles
- **Progressive Composition** — Build music incrementally, not all at once
- **Surgical Control** — Regenerate individual layers without affecting others
- **Ownership Through Process** — Multiple creative decisions = authentic ownership
- **Simplicity Over Features** — No knobs, no DAW complexity, just describe and refine

## Tech Stack (Actual)
- **Framework**: Next.js 16, React 19, TypeScript, App Router
- **Styling**: Tailwind CSS 4, Radix UI (headless components), lucide-react icons
- **AI Chat**: Vercel AI SDK v6 — GPT-5 Nano (Normal mode) + Claude Opus 4.6 (Agent mode)
- **AI Music**: Suno API (unlimited TreeHacks credits)
- **Stem Separation**: Suno /stem (12 stems) + Modal Demucs (3 stems, parallel, faster)
- **Audio**: waveform-playlist (multitrack waveforms) + Web Audio API (mixing)
- **Persistence**: localStorage only (no database)
- **Deploy**: Vercel (vercel.json configured, region iad1) + Modal (Demucs GPU endpoint)
- **Testing**: Vitest + Testing Library + Playwright

## Environment Variables
```
SUNO_API_KEY=<bearer token from Suno TreeHacks booth>
OPENAI_API_KEY=<sk-... for gpt-5-nano chat>
ANTHROPIC_API_KEY=<sk-ant-... for Claude Opus agent mode>
MODAL_DEMUCS_URL=<https://your-username--layertune-demucs.modal.run>
```

## Commands
```bash
cd app && npm run dev     # Next.js dev server (port 3000)
cd app && npm run build   # Production build
cd app && npm test         # Vitest run
cd app && npm run test:watch  # Vitest watch mode
```

## File Structure (Actual)
```
app/                          # Next.js app root (all code lives here)
├── app/                      # App Router
│   ├── page.tsx              # Landing page (hero, animated card gallery)
│   ├── layout.tsx            # Root layout with Vercel analytics
│   ├── studio/
│   │   └── page.tsx          # ★ Main studio orchestrator (~700 lines)
│   └── api/
│       ├── generate/route.ts # POST — Suno generate proxy
│       ├── stem/route.ts     # POST — Suno stem separation proxy
│       ├── stem-demucs/route.ts # POST — Modal Demucs stem separation
│       ├── clips/route.ts    # GET  — Suno clip status polling proxy
│       ├── chat/route.ts     # POST — AI chat (dual model + agent mode)
│       └── audio-proxy/route.ts  # GET — CDN audio proxy (Suno + Modal)
├── components/
│   ├── studio/               # App-specific components
│   │   ├── chat-panel.tsx       # AI chat interface (drag-layer-to-chat)
│   │   ├── layer-sidebar.tsx    # Layer list with mute/solo/volume
│   │   ├── waveform-display.tsx # Waveform container div
│   │   ├── transport-bar.tsx    # Play/pause/stop, seek bar, zoom, master volume
│   │   ├── generation-overlay.tsx # Spinner + phase text during generatio
[truncated — 11835 more characters]
```

### .claude/ralph-loop.49278.local.md

```markdown
# Ralph Loop State
- PID: 49278
- Iteration: 1
- Max iterations: 100
- Status: RUNNING
- Spec: app/docs/specs/music-video-generation.spec.md
- Mode: subagent (adapted from team — no Teammate tool available)

## Progress
| REQ | Status | Notes |
|-----|--------|-------|
| REQ-1 | COMPLETED | lib/heygen.ts — server client with retry, envelope unwrap |
| REQ-2 | COMPLETED | 4 proxy routes: upload, generate, status, streaming-token |
| REQ-3 | COMPLETED | DALL-E background generation route (GPT-4o-mini + gpt-image-1) |
| REQ-4 | COMPLETED | lib/lyrics-parser.ts — parseLyricsIntoSections + generateInstrumentalSections |
| REQ-5 | COMPLETED | Video types in layertune-types.ts, lyrics field on Project, setLyrics in hook |
| REQ-6 | COMPLETED | 7 client wrappers in lib/api.ts |
| REQ-7 | COMPLETED | components/video/camera-capture.tsx with fallback |
| REQ-8 | COMPLETED | components/video/style-selector.tsx with 4 modes |
| REQ-9 | COMPLETED | components/video/video-generation-overlay.tsx matching existing pattern |
| REQ-10 | COMPLETED | components/video/video-result.tsx with share/download |
| REQ-11 | COMPLETED | app/studio/video/page.tsx — full generation pipeline |
| REQ-12 | COMPLETED | "Music Video" button in studio header + router wiring |
| REQ-13 | COMPLETED | components/video/streaming-preview.tsx with graceful degradation |
| REQ-14 | COMPLETED | api/video/upload/route.ts — Vercel Blob storage |
| REQ-15 | COMPLETED | Dependencies installed, vercel.json updated |

## Verification
- Build: PASSED (npm run build — 0 errors, all routes visible)
- Tests: PASSED (46/46 tests pass across 6 test files)

```

### app/package.json

```
{
  "name": "producething",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "next build",
    "dev": "next dev",
    "start": "next start",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "@ai-sdk/anthropic": "^3.0.44",
    "@ai-sdk/openai": "^3.0.29",
    "@ai-sdk/react": "^3.0.88",
    "@heygen/streaming-avatar": "^2.1.0",
    "@hookform/resolvers": "^3.10.0",
    "@radix-ui/react-accordion": "1.2.2",
    "@radix-ui/react-alert-dialog": "1.1.4",
    "@radix-ui/react-aspect-ratio": "1.1.1",
    "@radix-ui/react-avatar": "1.1.2",
    "@radix-ui/react-checkbox": "1.1.3",
    "@radix-ui/react-collapsible": "1.1.2",
    "@radix-ui/react-context-menu": "2.2.4",
    "@radix-ui/react-dialog": "1.1.4",
    "@radix-ui/react-slider": "1.2.2",
    "@radix-ui/react-slot": "1.1.1",
    "@radix-ui/react-toast": "1.2.4",
    "@vercel/analytics": "1.3.1",
    "@vercel/blob": "^2.2.0",
    "ai": "^6.0.86",
    "autoprefixer": "^10.4.20",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "cmdk": "1.0.4",
    "date-fns": "4.1.0",
    "embla-carousel-react": "8.5.1",
    "event-emitter": "^0.3.5",
    "input-otp": "1.4.1",
    "livekit-client": "^2.17.1",
    "lucide-react": "^0.454.0",
    "next": "16.1.6",
    "next-themes": "^0.4.6",
    "openai": "^6.22.0",
    "react": "19.2.4",
    "react-day-picker": "9.8.0",
    "react-dom": "19.2.4",
    "react-hook-form": "^7.60.0",
    "react-markdown": "^10.1.0",
    "syllable": "^5.0.1",
    "tailwind-merge": "^3.3.1",
    "tailwindcss-animate": "^1.0.7",
    "vaul": "^1.1.2",
    "waveform-playlist": "^4.3.3",
    "zod": "3.25.76"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4.1.9",
    "@testing-library/dom": "^10.4.1",
    "@testing-library/jest-dom": "^6.9.1",
    "@testing-library/react": "^16.3.2",
    "@types/node": "^22",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "@vitejs/plugin-react": "^5.1.4",
    "jsdom": "^28.0.0",
    "postcss": "^8.5",
    "tailwindcss": "^4.1.9",
    "tw-animate-css": "1.3.3",
    "typescript": "^5",
    "vitest": "^4.0.18"
  }
}

```

### app/app/layout.tsx

```typescript
import type { Metadata } from 'next'
import { Geist, Geist_Mono, DM_Serif_Display } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import './globals.css'

const _geist = Geist({ subsets: ["latin"] }); // eslint-disable-line @typescript-eslint/no-unused-vars
const _geistMono = Geist_Mono({ subsets: ["latin"] }); // eslint-disable-line @typescript-eslint/no-unused-vars
const _dmSerifDisplay = DM_Serif_Display({ // eslint-disable-line @typescript-eslint/no-unused-vars
  subsets: ["latin"],
  weight: "400"
});

export const metadata: Metadata = {
  title: 'ProduceThing Studio',
  description: 'AI-powered layer-by-layer music creation by Duy',
  icons: {
    icon: '/producething_brandmark.svg',
  },
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body className={`font-sans antialiased`}>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

```

### app/app/page.tsx

```typescript
"use client"

import { useEffect, useRef, useState } from "react"
import { ChevronDown } from 'lucide-react'
import Link from "next/link"
import Image from "next/image"
import { useRouter } from "next/navigation"

const QUICK_PROMPTS = [
  { label: "Lofi chill beats", prompt: "lofi hip hop, chill, rainy day vibes, nostalgic" },
  { label: "Trap banger", prompt: "trap, 808s, hard hitting drums, dark energy" },
  { label: "Acoustic folk", prompt: "acoustic guitar, folk, warm, storytelling" },
  { label: "Synth pop", prompt: "synth pop, 80s inspired, bright, danceable" },
  { label: "Jazz vibes", prompt: "smooth jazz, saxophone, piano, late night" },
  { label: "EDM drop", prompt: "edm, electronic, heavy bass drop, festival energy" },
];

function seededRandom(seed: number) {
  const x = Math.sin(seed + 1) * 10000;
  return x - Math.floor(x);
}

const CARD_OFFSETS = Array.from({ length: 17 }, (_, i) => seededRandom(i) * 400 - 200);

const CARDS = [
  {
    image: "/images/1.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.75 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.95 },
    exploded: { x: -3200 + CARD_OFFSETS[0], y: -280, opacity: 1, scale: 0.85, rotation: 0 },
    row: { x: -3200, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/2.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.8 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.9 },
    exploded: { x: -2800 + CARD_OFFSETS[1], y: -200, opacity: 1, scale: 0.9, rotation: 0 },
    row: { x: -2800, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/3.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.85 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.88 },
    exploded: { x: -2400 + CARD_OFFSETS[2], y: -150, opacity: 1, scale: 0.95, rotation: 0 },
    row: { x: -2400, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/4.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.8 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.85 },
    exploded: { x: -2000 + CARD_OFFSETS[3], y: -100, opacity: 1, scale: 1.1, rotation: 0 },
    row: { x: -2000, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/5.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.78 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.82 },
    exploded: { x: -1600 + CARD_OFFSETS[4], y: -120, opacity: 1, scale: 0.92, rotation: 0 },
    row: { x: -1600, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/6.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.82 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.8 },
    exploded: { x: -1200 + CARD_OFFSETS[5], y: -180, opacity: 1, scale: 0.9, rotation: 0 },
    row: { x: -1200, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/7.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.8 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.78 },
    exploded: { x: -800 + CARD_OFFSETS[6], y: -240, opacity: 1, scale: 0.88, rotation: 0 },
    row: { x: -800, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/9.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.8 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.72 },
    exploded: { x: -400 + CARD_OFFSETS[7], y: 50, opacity: 1, scale: 0.83, rotation: 0 },
    row: { x: -400, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/10.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.7 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.68 },
    exploded: { x: 0 + CARD_OFFSETS[8], y: -100, opacity: 1, scale: 0.82, rotation: 0 },
    row: { x: 0, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/11.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.8 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.65 },
    exploded: { x: 400 + CARD_OFFSETS[9], y: -60, opacity: 1, scale: 0.8, rotation: 0 },
    row: { x: 400, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/12.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.72 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.5 },
    exploded: { x: 800 + CARD_OFFSETS[10], y: 200, opacity: 1, scale: 0.78, rotation: 0 },
    row: { x: 800, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/13.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.74 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.6 },
    exploded: { x: 1200 + CARD_OFFSETS[11], y: 150, opacity: 1, scale: 0.88, rotation: 0 },
    row: { x: 1200, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/16.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.8 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.58 },
    exploded: { x: 1600 + CARD_OFFSETS[12], y: -120, opacity: 1, scale: 0.82, rotation: 0 },
    row: { x: 1600, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/14.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.8 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.52 },
    exploded: { x: 2000 + CARD_OFFSETS[13], y: 180, opacity: 1, scale: 0.8, rotation: 0 },
    row: { x: 2000, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/15.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.72 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.5 },
    exploded: { x: 2400 + CARD_OFFSETS[14], y: 100, opacity: 1, scale: 0.86, rotation: 0 },
    row: { x: 2400, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/8.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.8 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.48 },
    exploded: { x: 2800 + CARD_OFFSETS[15], y: 140, opacity: 1, scale: 0.84, rotation: 0 },
    row: { x: 2800, y: 380, opacity: 1, scale: 1, rotation: 0 },
  },
  {
    image: "/images/4.png",
    initial: { x: 0, y: 0, opacity: 0, scale: 0.68 },
    descending: { x: 0, y: 250, opacity: 1, scale: 0.4
[truncated — 11861 more characters]
```

### app/src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ToastProvider } from "@/context/ToastContext";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "LayerTune - AI Music Composition",
  description: "Layer-by-layer music composition through natural language",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" className="dark">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased bg-zinc-950 text-white`}
      >
        <ToastProvider>
          {children}
        </ToastProvider>
      </body>
    </html>
  );
}

```

### app/src/app/page.tsx

```typescript
'use client';

import React, { useRef, useState, useCallback, useEffect } from 'react';
import { Layers } from 'lucide-react';
import { CreatePanel } from '@/components/CreatePanel';
import { LayerTimeline } from '@/components/LayerTimeline';
import { TransportBar } from '@/components/TransportBar';
import { GenerationStatus, GenerationPhase } from '@/components/GenerationStatus';
import { RegenerateModal } from '@/components/RegenerateModal';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ExportPanel } from '@/components/ExportPanel';
import { useProject } from '@/hooks/useProject';
import { useWaveformPlaylist } from '@/hooks/useWaveformPlaylist';
import { useToast } from '@/context/ToastContext';
import { Layer, CachedStem, StemType } from '@/lib/types';
import { generate, stem, pollUntilDone, proxyAudioUrl, stemTitleToType } from '@/lib/api';
import { STEM_TYPE_TAGS, STEM_DISPLAY_NAMES, POLL_INTERVALS } from '@/lib/constants';

export default function Home() {
  const {
    project,
    addLayer,
    removeLayer,
    toggleMute,
    toggleSolo,
    setLayerVolume,
    setVibePrompt,
    updateLayer,
    setOriginalClipId,
    setStemCache,
    startABComparison,
    setABState,
  } = useProject();

  const { showToast } = useToast();
  const playlistContainerRef = useRef<HTMLDivElement>(null);

  // Transport state
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [masterVolume, setMasterVolume] = useState(0.8);
  const [zoomLevel, setZoomLevel] = useState(1);

  // A/B selected versions (which version is currently audible per layer)
  const [abSelectedVersions, setAbSelectedVersions] = useState<Record<string, 'a' | 'b'>>({});

  // Waveform playlist integration
  const {
    play: playAudio,
    pause: pauseAudio,
    stop: stopAudio,
    rewind: rewindAudio,
    exportAudio,
  } = useWaveformPlaylist({
    containerRef: playlistContainerRef,
    layers: project.layers,
    masterVolume,
    zoomLevel,
    onTimeUpdate: setCurrentTime,
    onDurationChange: setDuration,
    onFinish: () => setIsPlaying(false),
  });

  // Sync play/pause state with playlist
  useEffect(() => {
    if (isPlaying) {
      playAudio();
    } else {
      pauseAudio();
    }
  }, [isPlaying, playAudio, pauseAudio]);

  // Generation state
  const [generationPhase, setGenerationPhase] = useState<GenerationPhase>('idle');
  const [isGenerating, setIsGenerating] = useState(false);

  // Modal state — regenKey forces remount to reset internal state
  const [regenModal, setRegenModal] = useState<{ isOpen: boolean; layer: Layer | null; key: number }>({
    isOpen: false,
    layer: null,
    key: 0,
  });
  const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; layerId: string | null }>({
    isOpen: false,
    layerId: null,
  });

  // Keyboard shortcuts
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
      if (e.code === 'Space') {
        e.preventDefault();
        setIsPlaying((prev) => !prev);
      } else if (e.key === 'r' || e.key === 'R') {
        setCurrentTime(0);
        rewindAudio();
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [rewindAudio]);

  // Swap audioUrl <-> previousAudioUrl to toggle which version is audible.
  // Only swaps when the requested version differs from current to prevent double-swap.
  const handleSelectABVersion = useCallback(
    (layerId: string, version: 'a' | 'b') => {
      const currentVersion = abSelectedVersions[layerId] || 'b';
      if (currentVersion === version) return;

      setAbSelectedVersions((prev) => ({ ...prev, [layerId]: version }));
      const layer = project.layers.find((l) => l.id === layerId);
      if (!layer || !layer.previousAudioUrl) return;

      updateLayer(layerId, {
        audioUrl: layer.previousAudioUrl,
        previousAudioUrl: layer.audioUrl,
      });
    },
    [project.layers, updateLayer, abSelectedVersions]
  );

  // Resolve an A/B comparison by keeping the specified version.
  // If the user toggled to a different version than what's requested,
  // we need to swap audioUrl <-> previousAudioUrl before clearing.
  const resolveABComparison = useCallback(
    (layerId: string, keepVersion: 'a' | 'b') => {
      const currentVersion = abSelectedVersions[layerId] || 'b';
      if (currentVersion !== keepVersion) {
        const layer = project.layers.find((l) => l.id === layerId);
        if (layer?.previousAudioUrl) {
          updateLayer(layerId, {
            audioUrl: layer.previousAudioUrl,
            previousAudioUrl: null,
          });
        }
      } else {
        updateLayer(layerId, { previousAudioUrl: null });
      }
      setAbSelectedVersions((prev) => {
        const next = { ...prev };
        delete next[layerId];
        return next;
      });
      setABState(layerId, 'none');
    },
    [abSelectedVersions, project.layers, updateLayer, setABState]
  );

  const handleKeepA = useCallback(
    (layerId: string) => {
      resolveABComparison(layerId, 'a');
      showToast('Reverted to original version', 'info');
    },
    [resolveABComparison, showToast]
  );

  const handleKeepB = useCallback(
    (layerId: string) => {
      resolveABComparison(layerId, 'b');
      showToast('New version kept!', 'success');
    },
    [resolveABComparison, showToast]
  );

  const handleGenerate = useCallback(
    async (prompt: string, tags?: string, instrumental?: boolean) => {
      setIsGenerating(true);
      setGenerationPhase('generating');
      setVibePrompt(prompt);

      try {
        // Emphasize drums in tags since we show that layer first
        const drumsTags = `drums, beat, rhythm, ${tags || prompt}`;
        const data = await gen
[truncated — 11618 more characters]
```

### app/app/api/stem/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { stemClip } from "@/lib/suno";

export async function POST(req: NextRequest) {
  try {
    const { clip_id } = await req.json();
    if (!clip_id || typeof clip_id !== "string") {
      return NextResponse.json({ error: "clip_id (string) required" }, { status: 400 });
    }
    const result = await stemClip(clip_id);
    return NextResponse.json(result);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Stem separation failed";
    const statusMatch = message.match(/\((\d{3})\)/);
    const status = statusMatch ? parseInt(statusMatch[1], 10) : 500;
    console.error(`[stem] ${status}: ${message}`);
    return NextResponse.json({ error: message }, { status });
  }
}

```

### app/app/api/clips/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { getClips } from "@/lib/suno";

export async function GET(req: NextRequest) {
  try {
    const ids = req.nextUrl.searchParams.get("ids");
    if (!ids) {
      return NextResponse.json({ error: "ids parameter required" }, { status: 400 });
    }

    const idList = ids.split(",").filter(Boolean);
    if (idList.length === 0) {
      return NextResponse.json({ error: "At least one clip ID required" }, { status: 400 });
    }

    if (idList.length > 20) {
      return NextResponse.json(
        { error: "Too many clip IDs (max 20 per request)" },
        { status: 400 }
      );
    }

    const result = await getClips(idList);
    return NextResponse.json(result);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Clips fetch failed";
    const status = message.includes("(429)") ? 429 : 500;
    return NextResponse.json({ error: message }, { status });
  }
}

```

### app/app/api/generate/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { generateTrack } from "@/lib/suno";
import { SunoGenerateRequest } from "@/lib/layertune-types";

export async function POST(req: NextRequest) {
  try {
    const body: SunoGenerateRequest = await req.json();

    if (!body.topic && !body.tags && !body.prompt) {
      return NextResponse.json(
        { error: "At least one of topic, tags, or prompt is required" },
        { status: 400 }
      );
    }

    if (body.topic && body.topic.length > 500) {
      return NextResponse.json(
        { error: "Topic too long. Please keep it under 500 characters." },
        { status: 400 }
      );
    }

    if (body.tags && body.tags.length > 100) {
      return NextResponse.json(
        { error: "Tags too long. Please keep it under 100 characters." },
        { status: 400 }
      );
    }

    if (body.negative_tags && body.negative_tags.length > 100) {
      return NextResponse.json(
        { error: "Negative tags too long. Please keep it under 100 characters." },
        { status: 400 }
      );
    }

    const result = await generateTrack(body);
    return NextResponse.json(result);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Generation failed";
    // Extract actual HTTP status from Suno error messages like "Suno request failed (400): ..."
    const statusMatch = message.match(/\((\d{3})\)/);
    const status = statusMatch ? parseInt(statusMatch[1], 10) : 500;
    console.error(`[generate] ${status}: ${message}`);
    return NextResponse.json({ error: message }, { status });
  }
}

```

[126 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]